refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)
- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bda8304bb9
commit
c48a0c5e3b
@@ -0,0 +1,248 @@
|
||||
import { Component, lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { AuthOverlay } from "./features/auth";
|
||||
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 {
|
||||
mergeMessages,
|
||||
useMessages,
|
||||
} from "./features/messages/hooks/useMessages";
|
||||
import {
|
||||
type ActiveSpeaker,
|
||||
getAppConfig,
|
||||
type MediaState,
|
||||
type MessageRecord,
|
||||
} from "./shared/api/client";
|
||||
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||
import { useUIState } from "./shared/hooks/useUIState";
|
||||
import { Skeleton } from "./shared/ui";
|
||||
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||
import { useDashboardSocket } from "./shared/ws/socket";
|
||||
import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||
|
||||
const AnalyticsPanel = lazy(() =>
|
||||
import("./features/analytics").then((module) => ({
|
||||
default: module.AnalyticsPanel,
|
||||
})),
|
||||
);
|
||||
|
||||
class AnalyticsErrorBoundary extends Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 p-6 text-sm text-destructive">
|
||||
Analytics failed to load. The rest of the dashboard is still
|
||||
available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { uiState, patchUIState } = useUIState();
|
||||
const voice = useVoiceControl();
|
||||
const media = useMediaControl();
|
||||
const messages = useMessages();
|
||||
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(
|
||||
!!localStorage.getItem("admin-password"),
|
||||
);
|
||||
const [monitorGuildId, setMonitorGuildId] = useState("");
|
||||
|
||||
const audio = useAudioPlayback();
|
||||
const activeTab = uiState.activeTab || "live";
|
||||
const selectedVoiceGuild =
|
||||
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||
const selectedTextGuild =
|
||||
monitorGuildId || uiState.selectedTextGuild || uiState.selectedGuild || "";
|
||||
const selectedTextChannel = uiState.selectedTextChannel || "";
|
||||
const monitorGuild = useMemo(
|
||||
() =>
|
||||
monitorGuildId
|
||||
? voice.guilds.find((g) => g.id === monitorGuildId)
|
||||
: undefined,
|
||||
[monitorGuildId, voice.guilds],
|
||||
);
|
||||
|
||||
const socket = useDashboardSocket({
|
||||
onBinary: audio.handleIncomingPcm,
|
||||
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
|
||||
onMessageCreated: (m) =>
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||
onMessageUpdated: (m) => {
|
||||
const d = m as Partial<MessageRecord> & { id: string };
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) => (i.id === d.id ? { ...i, ...d } : i)),
|
||||
);
|
||||
},
|
||||
onMessageDeleted: (m) => {
|
||||
const d = m as { id: string };
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === d.id ? { ...i, type: "deleted" as const } : i,
|
||||
),
|
||||
);
|
||||
},
|
||||
onMessageAnalyzed: (m) =>
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||
onAttachmentUploaded: () =>
|
||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
||||
onMediaState: (state) => media.setMediaState(state as MediaState),
|
||||
onVoiceRecordingUploaded: (d) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("voice_recording_uploaded", { detail: d }),
|
||||
),
|
||||
});
|
||||
|
||||
const transmit = useAudioTransmit(socket.socketRef);
|
||||
|
||||
useEffect(() => {
|
||||
getAppConfig()
|
||||
.then((c) => {
|
||||
if (c.monitorGuildId) {
|
||||
setMonitorGuildId(c.monitorGuildId);
|
||||
patchUIState({
|
||||
selectedTextGuild: c.monitorGuildId,
|
||||
selectedAnalyticsGuild: c.monitorGuildId,
|
||||
selectedTextChannel: "",
|
||||
selectedAnalyticsChannel: "",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [patchUIState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVoiceGuild)
|
||||
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||
useEffect(() => {
|
||||
if (monitorGuildId)
|
||||
voice.loadTextTargets(monitorGuildId).catch(() => undefined);
|
||||
}, [monitorGuildId, voice.loadTextTargets]);
|
||||
useEffect(() => {
|
||||
if (selectedTextChannel)
|
||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||
}, [selectedTextChannel, messages.fetchMessages]);
|
||||
|
||||
// Periodic refetch — ensures dashboard stays in sync even if WS events were missed
|
||||
useEffect(() => {
|
||||
if (!selectedTextChannel) return;
|
||||
const interval = setInterval(() => {
|
||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||
}, 15_000); // every 15s (longer than WS, shorter than stale cache)
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedTextChannel, messages.fetchMessages]);
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
activeTab={activeTab}
|
||||
wsStatus={socket.status}
|
||||
voiceStatus={voice.voiceStatus}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
>
|
||||
{activeTab === "live" ? (
|
||||
!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<LivePanel
|
||||
guilds={voice.guilds}
|
||||
voiceChannels={voice.voiceChannels}
|
||||
selectedGuild={selectedVoiceGuild}
|
||||
selectedChannel={uiState.selectedVoiceChannel || ""}
|
||||
status={voice.voiceStatus}
|
||||
voiceLoading={voice.loading}
|
||||
activeSpeakers={activeSpeakers}
|
||||
levels={audio.levels}
|
||||
isListening={audio.isListening}
|
||||
isStreaming={transmit.isStreaming}
|
||||
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 === "messages" ? (
|
||||
<MessagesPanel
|
||||
guilds={monitorGuild ? [monitorGuild] : []}
|
||||
channels={voice.textChannels}
|
||||
selectedGuild={selectedTextGuild}
|
||||
selectedChannel={selectedTextChannel}
|
||||
messages={messages.messages}
|
||||
onGuildChange={(id) =>
|
||||
patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })
|
||||
}
|
||||
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
|
||||
onReanalyze={messages.reanalyze}
|
||||
onLoadMore={messages.loadMore}
|
||||
hasMore={messages.hasMore}
|
||||
loadingMore={messages.loadingMore}
|
||||
/>
|
||||
) : (
|
||||
<AnalyticsErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalyticsPanel
|
||||
guilds={monitorGuild ? [monitorGuild] : []}
|
||||
channels={voice.textChannels}
|
||||
selectedGuild={
|
||||
uiState.selectedAnalyticsGuild || selectedTextGuild || ""
|
||||
}
|
||||
selectedChannel={
|
||||
uiState.selectedAnalyticsChannel || selectedTextChannel || ""
|
||||
}
|
||||
onGuildChange={(id) =>
|
||||
patchUIState({
|
||||
selectedAnalyticsGuild: id,
|
||||
selectedAnalyticsChannel: "",
|
||||
})
|
||||
}
|
||||
onChannelChange={(id) =>
|
||||
patchUIState({ selectedAnalyticsChannel: id })
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalyticsErrorBoundary>
|
||||
)}
|
||||
<MobileTabBar
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type MediaMode = "music" | "screen";
|
||||
|
||||
export interface MediaItem {
|
||||
id?: string;
|
||||
source: string;
|
||||
title: string;
|
||||
mode?: MediaMode;
|
||||
durationMs?: number | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
|
||||
export type AISeverity = "none" | "low" | "medium" | "high" | "critical";
|
||||
export type AIRecommendedAction =
|
||||
| "none"
|
||||
| "monitor"
|
||||
| "warn"
|
||||
| "review"
|
||||
| "delete"
|
||||
| "escalate";
|
||||
|
||||
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 }>;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
edited_content: string | null;
|
||||
created_at: number;
|
||||
edited_at: number | null;
|
||||
deleted_at: number | null;
|
||||
type: "text" | "edited" | "deleted";
|
||||
metadata: string | null;
|
||||
ai_status?: AIStatus | null;
|
||||
ai_moderation_flags?: string | null;
|
||||
ai_moderation_score?: number | null;
|
||||
ai_analysis?: string | null;
|
||||
ai_categories?: string | null;
|
||||
ai_severity?: AISeverity | null;
|
||||
ai_confidence?: number | null;
|
||||
ai_recommended_action?: AIRecommendedAction | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type DashboardTab = "live" | "messages" | "analytics";
|
||||
|
||||
export interface UIState {
|
||||
selectedGuild?: string;
|
||||
selectedVoiceGuild?: string;
|
||||
selectedVoiceChannel?: string;
|
||||
selectedTextGuild?: string;
|
||||
selectedTextChannel?: string;
|
||||
selectedAnalyticsGuild?: string;
|
||||
selectedAnalyticsChannel?: string;
|
||||
activeTab?: DashboardTab;
|
||||
isListening?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface VoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId?: string | null;
|
||||
activeChannelId?: string | null;
|
||||
activeChannelName?: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveSpeaker {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
speaking: boolean;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { HourlyBucket } from "../../../shared/api/client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface ActivityChartProps {
|
||||
hourly: HourlyBucket[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
if (loading && !hourly?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!hourly?.length) {
|
||||
return <EmptyBox text="Belum ada data untuk periode ini." />;
|
||||
}
|
||||
|
||||
const data = hourly.map((b) => {
|
||||
const utcHour = parseInt(b.hour.slice(11, 13), 10);
|
||||
const jakartaHour = (utcHour + 7) % 24;
|
||||
return {
|
||||
hour: `${String(jakartaHour).padStart(2, "0")}:00`,
|
||||
clean: b.clean,
|
||||
warned: b.warned,
|
||||
flagged: b.flagged,
|
||||
error: b.error,
|
||||
total: b.count,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="col-span-1 lg:col-span-2 glass border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
Aktivitas per Jam
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Distribusi pesan per jam berdasarkan status moderasi.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span>Clean</span>
|
||||
<span>Warned</span>
|
||||
<span>Flagged</span>
|
||||
<span>Error</span>
|
||||
</div>
|
||||
<div className="max-h-55 space-y-2 overflow-auto pr-1">
|
||||
{data.map((bucket) => {
|
||||
const total = Math.max(bucket.total, 1);
|
||||
const clean = bucket.clean / total;
|
||||
const warned = bucket.warned / total;
|
||||
const flagged = bucket.flagged / total;
|
||||
const error = bucket.error / total;
|
||||
return (
|
||||
<div
|
||||
key={bucket.hour}
|
||||
className="grid gap-1 rounded-xl border border-border bg-background/50 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{bucket.hour}
|
||||
</span>
|
||||
<span>{bucket.total} pesan</span>
|
||||
</div>
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-emerald-500/80"
|
||||
style={{ width: `${clean * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-amber-500/80"
|
||||
style={{ width: `${warned * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-500/80"
|
||||
style={{ width: `${flagged * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-orange-500/80"
|
||||
style={{ width: `${error * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2 glass border-white/5">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBox({ text }: { text: string }) {
|
||||
return (
|
||||
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2 glass border-white/5">
|
||||
{text}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Activity, BarChart3 } from "lucide-react";
|
||||
import type { Channel, Guild } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: "1j", value: 1 },
|
||||
{ label: "3j", value: 3 },
|
||||
{ label: "6j", value: 6 },
|
||||
{ label: "12j", value: 12 },
|
||||
{ label: "24j", value: 24 },
|
||||
{ label: "48j", value: 48 },
|
||||
{ label: "7h", value: 168 },
|
||||
];
|
||||
|
||||
interface ControlBarProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
hours: number;
|
||||
isFetching: boolean;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onHoursChange: (hours: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ControlBar({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
hours,
|
||||
isFetching,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onHoursChange,
|
||||
onRefresh,
|
||||
}: ControlBarProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||
Analisis Moderasi
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Pantau statistik, tren topik, dan aktivitas user.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(e) => onGuildChange(e.target.value)}
|
||||
placeholder="Pilih guild"
|
||||
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||
className="min-w-[180px]"
|
||||
/>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => onChannelChange(e.target.value)}
|
||||
placeholder="Semua channel"
|
||||
options={[
|
||||
{ value: "", label: "Semua channel" },
|
||||
...channels.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
className="min-w-[160px]"
|
||||
/>
|
||||
<div className="flex items-center gap-1 rounded-md bg-muted p-0.5">
|
||||
{TIME_RANGES.map((tr) => (
|
||||
<button
|
||||
key={tr.value}
|
||||
type="button"
|
||||
onClick={() => onHoursChange(tr.value)}
|
||||
className={cn(
|
||||
"rounded-sm px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
hours === tr.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tr.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isFetching}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0"
|
||||
>
|
||||
{isFetching ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Memuat...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMemo } from "react";
|
||||
import type { HeatmapCell } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
const DAYS = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
|
||||
|
||||
interface HeatmapProps {
|
||||
cells: HeatmapCell[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function Heatmap({ cells, loading }: HeatmapProps) {
|
||||
const maxCount = useMemo(
|
||||
() => Math.max(1, ...cells.map((c) => c.count)),
|
||||
[cells],
|
||||
);
|
||||
|
||||
if (loading && !cells?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!cells?.length) {
|
||||
return <EmptyBox />;
|
||||
}
|
||||
|
||||
const cellMap = new Map<string, HeatmapCell>();
|
||||
for (const c of cells) cellMap.set(`${c.dayOfWeek}-${c.hour}`, c);
|
||||
|
||||
function getIntensity(day: number, hour: number): number {
|
||||
return (cellMap.get(`${day}-${hour}`)?.count ?? 0) / maxCount;
|
||||
}
|
||||
|
||||
function getHeatClass(intensity: number): string {
|
||||
if (intensity === 0) return "bg-muted/30";
|
||||
if (intensity < 0.1) return "bg-blue-500/10";
|
||||
if (intensity < 0.2) return "bg-blue-500/20";
|
||||
if (intensity < 0.35) return "bg-blue-500/30";
|
||||
if (intensity < 0.5) return "bg-blue-500/45";
|
||||
if (intensity < 0.7) return "bg-blue-500/60";
|
||||
return "bg-blue-500/80";
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
Heatmap Aktivitas
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Hari × jam — area biru = lebih ramai.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[520px]">
|
||||
{/* Header row */}
|
||||
<div className="mb-1 ml-8 flex gap-[2px]">
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="flex-1 text-center text-[9px] text-muted-foreground tabular-nums"
|
||||
>
|
||||
{h % 3 === 0 ? `${h}` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{DAYS.map((day, d) => (
|
||||
<div key={d} className="mb-[2px] flex items-center gap-[2px]">
|
||||
<div className="w-8 shrink-0 text-right pr-1 text-[10px] text-muted-foreground">
|
||||
{day}
|
||||
</div>
|
||||
{Array.from({ length: 24 }, (_, h) => {
|
||||
const intensity = getIntensity(d, h);
|
||||
const cell = cellMap.get(`${d}-${h}`);
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
className={cn(
|
||||
"flex-1 rounded-sm aspect-square",
|
||||
getHeatClass(intensity),
|
||||
)}
|
||||
title={`${day} ${h}:00 — ${cell?.count ?? 0} pesan`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>Sepi</span>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-muted/30" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/20" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/45" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/80" />
|
||||
<span>Ramai</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBox() {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada data heatmap.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ModerationBreakdown } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import { Card, CardContent, Skeleton } from "../../../shared/ui";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
messages: ModerationBreakdown | null;
|
||||
activeUsersCount: number;
|
||||
totalChannels: number;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SummaryCards({
|
||||
messages,
|
||||
activeUsersCount,
|
||||
totalChannels,
|
||||
loading,
|
||||
}: SummaryCardsProps) {
|
||||
const avgPerHour = messages
|
||||
? Math.round(messages.total / Math.max(1, 24))
|
||||
: 0;
|
||||
const cleanPct =
|
||||
messages && messages.total > 0
|
||||
? Math.round((messages.clean / messages.total) * 100)
|
||||
: 0;
|
||||
const warnedPct =
|
||||
messages && messages.total > 0
|
||||
? Math.round((messages.warned / messages.total) * 100)
|
||||
: 0;
|
||||
const flaggedPct =
|
||||
messages && messages.total > 0
|
||||
? Math.round((messages.flagged / messages.total) * 100)
|
||||
: 0;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: "Total Pesan",
|
||||
value: formatNum(messages?.total),
|
||||
accent: "text-foreground",
|
||||
},
|
||||
{
|
||||
label: "Rata-rata/jam",
|
||||
value: formatNum(avgPerHour),
|
||||
accent: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
label: "Clean",
|
||||
value: cleanPct > 0 ? `${cleanPct}%` : "—",
|
||||
accent: "text-emerald-400",
|
||||
},
|
||||
{
|
||||
label: "Warned",
|
||||
value: warnedPct > 0 ? `${warnedPct}%` : "—",
|
||||
accent: "text-amber-400",
|
||||
},
|
||||
{
|
||||
label: "Flagged",
|
||||
value: flaggedPct > 0 ? `${flaggedPct}%` : "—",
|
||||
accent: "text-red-400",
|
||||
},
|
||||
{
|
||||
label: "Pending",
|
||||
value: formatNum(messages?.pending),
|
||||
accent: "text-slate-400",
|
||||
},
|
||||
{
|
||||
label: "User Aktif",
|
||||
value: formatNum(activeUsersCount),
|
||||
accent: "text-violet-400",
|
||||
},
|
||||
{
|
||||
label: "Channel",
|
||||
value: formatNum(totalChannels),
|
||||
accent: "text-blue-400",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} className="overflow-hidden glass border-white/5">
|
||||
<CardContent className="p-3">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{card.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 font-mono text-lg font-bold tabular-nums",
|
||||
card.accent,
|
||||
)}
|
||||
>
|
||||
{loading ? <Skeleton className="h-7 w-12 mt-1" /> : card.value}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNum(v: number | undefined | null): string {
|
||||
if (v == null || v === 0) return "—";
|
||||
return v.toLocaleString("id-ID");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Flame } from "lucide-react";
|
||||
import type { TopicTrend } from "../../../shared/api/client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ScrollArea,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface TopicListProps {
|
||||
topics: TopicTrend[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function TopicList({ topics, loading }: TopicListProps) {
|
||||
if (loading && !topics?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!topics?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Topik akan muncul setelah AI selesai menganalisis.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...topics.map((t) => t.count), 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Flame className="h-4 w-4 text-orange-400" />
|
||||
Topik Trending
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Yang paling ramai dibicarakan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<div className="divide-y divide-border/30">
|
||||
{topics.map((topic, i) => (
|
||||
<div
|
||||
key={topic.topic}
|
||||
className="flex items-center gap-3 px-5 py-2 text-sm"
|
||||
>
|
||||
<span className="w-5 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{topic.topic}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500/60"
|
||||
style={{ width: `${(topic.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{topic.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { TrendBucket } from "../../../shared/api/client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface TrendChartProps {
|
||||
trend: TrendBucket[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
if (loading && !trend?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!trend?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = trend.map((bucket) => ({
|
||||
date: bucket.date,
|
||||
clean: bucket.clean,
|
||||
warned: bucket.warned,
|
||||
flagged: bucket.flagged,
|
||||
error: bucket.error,
|
||||
total: bucket.count,
|
||||
}));
|
||||
|
||||
const totalMessages = data.reduce((sum, item) => sum + item.total, 0);
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Volume pesan per hari dengan status moderasi.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<LegendDot color="bg-blue-500" label="Total" />
|
||||
<LegendDot color="bg-emerald-500" label="Clean" />
|
||||
<LegendDot color="bg-amber-500" label="Warned" />
|
||||
<LegendDot color="bg-red-500" label="Flagged" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-background/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>Rangkuman 7 hari terakhir</span>
|
||||
<span>{totalMessages} total pesan</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max((data.length - 1) * 56, 56)} 220`}
|
||||
className="h-55 min-w-130 w-full overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.35" />
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#3b82f6"
|
||||
stopOpacity="0.02"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g stroke="#334155" strokeWidth="1" opacity="0.35">
|
||||
{Array.from({ length: 4 }, (_, index) => {
|
||||
const y = 40 + index * 45;
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
x1="0"
|
||||
x2={Math.max((data.length - 1) * 56, 56)}
|
||||
y1={y}
|
||||
y2={y}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
<TrendArea
|
||||
data={data}
|
||||
keyName="total"
|
||||
fill="url(#trendFill)"
|
||||
stroke="#3b82f6"
|
||||
/>
|
||||
<TrendLine
|
||||
data={data}
|
||||
keyName="total"
|
||||
color="#3b82f6"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<TrendLine
|
||||
data={data}
|
||||
keyName="clean"
|
||||
color="#10b981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<TrendLine
|
||||
data={data}
|
||||
keyName="warned"
|
||||
color="#f59e0b"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<TrendLine
|
||||
data={data}
|
||||
keyName="flagged"
|
||||
color="#ef4444"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
|
||||
{data.map((item, index) => {
|
||||
const x =
|
||||
data.length <= 1
|
||||
? 0
|
||||
: (index / (data.length - 1)) *
|
||||
Math.max((data.length - 1) * 56, 56);
|
||||
return (
|
||||
<g key={item.date} transform={`translate(${x}, 188)`}>
|
||||
<circle cx="0" cy="0" r="2.5" fill="#e2e8f0" />
|
||||
<text
|
||||
x="0"
|
||||
y="18"
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[10px]"
|
||||
>
|
||||
{item.date.slice(5)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`h-2 w-2 rounded-full ${color}`} /> {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendLine({
|
||||
data,
|
||||
color,
|
||||
strokeWidth,
|
||||
keyName,
|
||||
}: {
|
||||
data: Array<Record<string, number | string>>;
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
keyName: string;
|
||||
}) {
|
||||
const path = buildPath(data, keyName, 220, false);
|
||||
|
||||
return (
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendArea({
|
||||
data,
|
||||
keyName,
|
||||
fill,
|
||||
stroke,
|
||||
}: {
|
||||
data: Array<Record<string, number | string>>;
|
||||
keyName: string;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
}) {
|
||||
const path = buildPath(data, keyName, 220, true);
|
||||
return <path d={path} fill={fill} stroke={stroke} strokeOpacity={0.2} />;
|
||||
}
|
||||
|
||||
function buildPath(
|
||||
data: Array<Record<string, number | string>>,
|
||||
keyName: string,
|
||||
height: number,
|
||||
closePath: boolean,
|
||||
): string {
|
||||
const values = data.map((item) => Number(item[keyName] ?? 0));
|
||||
const maxValue = Math.max(...values, 1);
|
||||
const width = Math.max((data.length - 1) * 56, 56);
|
||||
const points = values.map((value, index) => {
|
||||
const x = data.length <= 1 ? 0 : (index / (data.length - 1)) * width;
|
||||
const y = height - 35 - (value / maxValue) * 130;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const segments: string[] = [`M ${points[0].x} ${points[0].y}`];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
const controlX = (previous.x + current.x) / 2;
|
||||
segments.push(`Q ${controlX} ${previous.y} ${current.x} ${current.y}`);
|
||||
}
|
||||
|
||||
if (closePath) {
|
||||
const lastPoint = points[points.length - 1];
|
||||
const firstPoint = points[0];
|
||||
segments.push(`L ${lastPoint.x} ${height - 24}`);
|
||||
segments.push(`L ${firstPoint.x} ${height - 24}`);
|
||||
segments.push("Z");
|
||||
}
|
||||
|
||||
return segments.join(" ");
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardContent className="flex h-65 items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Users } from "lucide-react";
|
||||
import type { UserStat } from "../../../shared/api/client";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ScrollArea,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface UserTableProps {
|
||||
users: UserStat[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function UserTable({ users, loading }: UserTableProps) {
|
||||
if (loading && !users?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!users?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada aktivitas user.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxMsgs = Math.max(...users.map((u) => u.message_count), 1);
|
||||
const medals = ["🥇", "🥈", "🥉"];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Users className="h-4 w-4 text-violet-400" />
|
||||
User Paling Aktif
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Leaderboard berdasarkan jumlah pesan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||
<th className="py-2 pr-2 font-semibold">User</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Edit</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Hapus</th>
|
||||
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{users.map((user, i) => (
|
||||
<tr
|
||||
key={user.user_id}
|
||||
className="hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{medals[i] ?? i + 1}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-[100px] truncate text-xs font-medium">
|
||||
{user.username}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="h-1 w-8 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500/60"
|
||||
style={{
|
||||
width: `${(user.message_count / maxMsgs) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{user.message_count}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{user.edited_count > 0 ? user.edited_count : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{user.deleted_count > 0 ? user.deleted_count : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-right">
|
||||
{user.flagged_count > 0 ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-[9px] px-1 py-0"
|
||||
>
|
||||
{user.flagged_count}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Siren } from "lucide-react";
|
||||
import type { ViolatorStat } from "../../../shared/api/client";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ScrollArea,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface ViolatorTableProps {
|
||||
users: ViolatorStat[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ViolatorTable({ users, loading }: ViolatorTableProps) {
|
||||
if (loading && !users?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!users?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Tidak ada pelanggaran terdeteksi.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxScore = Math.max(...users.map((u) => u.violation_score), 1);
|
||||
|
||||
function dangerLabel(score: number) {
|
||||
if (score >= 10) return { variant: "destructive" as const, text: "HIGH" };
|
||||
if (score >= 5) return { variant: "warning" as const, text: "MED" };
|
||||
return { variant: "secondary" as const, text: "LOW" };
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Siren className="h-4 w-4 text-red-400" />
|
||||
Pelanggar Terbanyak
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Skor: flagged × 3 + warned × 1.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="destructive">{users.length} pelanggar</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||
<th className="py-2 pr-2 font-semibold">User</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Warned</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Flagged</th>
|
||||
<th className="py-2 pr-4 font-semibold text-right">Skor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{users.map((user, i) => {
|
||||
const danger = dangerLabel(user.violation_score);
|
||||
return (
|
||||
<tr
|
||||
key={user.user_id}
|
||||
className="hover:bg-red-500/5 transition-colors"
|
||||
>
|
||||
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-[100px] truncate text-xs font-medium">
|
||||
{user.username}
|
||||
</span>
|
||||
<Badge
|
||||
variant={danger.variant}
|
||||
className="text-[9px] px-1 py-0"
|
||||
>
|
||||
{danger.text}
|
||||
</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-xs text-amber-400 tabular-nums">
|
||||
{user.warned_count}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-xs text-red-400 tabular-nums">
|
||||
{user.flagged_count}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
user.violation_score >= 10
|
||||
? "bg-gradient-to-r from-red-600 to-red-400"
|
||||
: user.violation_score >= 5
|
||||
? "bg-gradient-to-r from-amber-500 to-amber-400"
|
||||
: "bg-gradient-to-r from-yellow-500 to-yellow-400",
|
||||
)}
|
||||
style={{
|
||||
width: `${(user.violation_score / maxScore) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-xs font-bold tabular-nums">
|
||||
{user.violation_score}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type {
|
||||
AnalyticsOverview,
|
||||
HeatmapCell,
|
||||
HourlyBucket,
|
||||
TopicTrend,
|
||||
TrendBucket,
|
||||
UserStat,
|
||||
ViolatorStat,
|
||||
} from "../../../shared/api/client";
|
||||
import {
|
||||
fetchAnalyticsOverview,
|
||||
fetchHeatmap,
|
||||
fetchTrend,
|
||||
fetchViolators,
|
||||
} from "../../../shared/api/client";
|
||||
|
||||
function analyticsKeys(
|
||||
guildId: string,
|
||||
channelId: string | undefined,
|
||||
hours: number,
|
||||
) {
|
||||
return {
|
||||
overview: [
|
||||
"analytics",
|
||||
"overview",
|
||||
guildId,
|
||||
channelId ?? "",
|
||||
hours,
|
||||
] as const,
|
||||
violators: [
|
||||
"analytics",
|
||||
"violators",
|
||||
guildId,
|
||||
channelId ?? "",
|
||||
hours,
|
||||
] as const,
|
||||
trend: ["analytics", "trend", guildId, channelId ?? "", hours] as const,
|
||||
heatmap: ["analytics", "heatmap", guildId, channelId ?? "", hours] as const,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAnalyticsOptions {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}
|
||||
|
||||
export function useAnalytics({
|
||||
guildId,
|
||||
channelId,
|
||||
hours = 24,
|
||||
}: UseAnalyticsOptions) {
|
||||
const keys = analyticsKeys(guildId, channelId, hours);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: keys.overview,
|
||||
queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const violatorsQuery = useQuery({
|
||||
queryKey: keys.violators,
|
||||
queryFn: () => fetchViolators({ guildId, channelId, hours, limit: 20 }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: keys.trend,
|
||||
queryFn: () => fetchTrend({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const heatmapQuery = useQuery({
|
||||
queryKey: keys.heatmap,
|
||||
queryFn: () => fetchHeatmap({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!guildId) return;
|
||||
window.dispatchEvent(new CustomEvent("analytics_refresh"));
|
||||
}, [guildId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!guildId) return;
|
||||
// Use queryClient.invalidateQueries from the React Query internals
|
||||
window.dispatchEvent(new CustomEvent("analytics_force_refresh"));
|
||||
};
|
||||
window.addEventListener("analytics_refresh", handler);
|
||||
return () => window.removeEventListener("analytics_refresh", handler);
|
||||
}, [refresh]);
|
||||
|
||||
const overview = overviewQuery.data ?? null;
|
||||
const isFetching = overviewQuery.isFetching && !overviewQuery.isLoading;
|
||||
const isLoading = overviewQuery.isLoading && !overviewQuery.data;
|
||||
|
||||
return {
|
||||
overview,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error:
|
||||
overviewQuery.error instanceof Error ? overviewQuery.error.message : null,
|
||||
refresh,
|
||||
|
||||
violators: violatorsQuery.data ?? [],
|
||||
violatorsLoading: violatorsQuery.isLoading && !violatorsQuery.data,
|
||||
violatorsFetching: violatorsQuery.isFetching && !violatorsQuery.isLoading,
|
||||
refreshViolators: () => {
|
||||
if (guildId) window.dispatchEvent(new CustomEvent("analytics_refresh"));
|
||||
},
|
||||
|
||||
trend: trendQuery.data ?? [],
|
||||
trendLoading: trendQuery.isLoading && !trendQuery.data,
|
||||
trendFetching: trendQuery.isFetching && !trendQuery.isLoading,
|
||||
|
||||
heatmap: heatmapQuery.data ?? [],
|
||||
heatmapLoading: heatmapQuery.isLoading && !heatmapQuery.data,
|
||||
heatmapFetching: heatmapQuery.isFetching && !heatmapQuery.isLoading,
|
||||
|
||||
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
|
||||
topics: overview?.topics ?? ([] as TopicTrend[]),
|
||||
topUsers: overview?.top_users ?? ([] as UserStat[]),
|
||||
messages: overview?.messages ?? null,
|
||||
period: overview?.period ?? null,
|
||||
activeUsersCount: overview?.active_users_count ?? 0,
|
||||
totalChannels: overview?.total_channels ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type {
|
||||
AnalyticsOverview,
|
||||
HeatmapCell,
|
||||
HourlyBucket,
|
||||
TopicTrend,
|
||||
TrendBucket,
|
||||
UserStat,
|
||||
ViolatorStat,
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import type { Channel, Guild } from "../../shared/api/client";
|
||||
import { ActivityChart } from "./components/ActivityChart";
|
||||
import { ControlBar } from "./components/ControlBar";
|
||||
import { Heatmap } from "./components/Heatmap";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TopicList } from "./components/TopicList";
|
||||
import { TrendChart } from "./components/TrendChart";
|
||||
import { UserTable } from "./components/UserTable";
|
||||
import { ViolatorTable } from "./components/ViolatorTable";
|
||||
import { useAnalytics } from "./hooks/useAnalytics";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
}: AnalyticsPanelProps) {
|
||||
const [hours, setHours] = useState(24);
|
||||
const analytics = useAnalytics({
|
||||
guildId: selectedGuild,
|
||||
channelId: selectedChannel || undefined,
|
||||
hours,
|
||||
});
|
||||
|
||||
const {
|
||||
hourly,
|
||||
topics,
|
||||
topUsers,
|
||||
activeUsersCount,
|
||||
totalChannels,
|
||||
violators,
|
||||
trend,
|
||||
heatmap,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refresh,
|
||||
refreshViolators,
|
||||
messages: analyticsMessages,
|
||||
} = analytics;
|
||||
const loading = isLoading && !isFetching;
|
||||
|
||||
if (error && !analyticsMessages) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedGuild) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pilih guild untuk melihat analitik.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlBar
|
||||
guilds={guilds}
|
||||
channels={channels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
hours={hours}
|
||||
isFetching={isFetching}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onHoursChange={setHours}
|
||||
onRefresh={() => {
|
||||
refresh();
|
||||
refreshViolators();
|
||||
}}
|
||||
/>
|
||||
<SummaryCards
|
||||
messages={analyticsMessages}
|
||||
activeUsersCount={activeUsersCount}
|
||||
totalChannels={totalChannels}
|
||||
loading={loading}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ActivityChart hourly={hourly} loading={loading} />
|
||||
<div className="col-span-1">
|
||||
<TopicList topics={topics} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
{hours >= 48 && <TrendChart trend={trend} loading={loading} />}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Heatmap cells={heatmap} loading={loading} />
|
||||
<div className="col-span-1">
|
||||
<UserTable users={topUsers} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<ViolatorTable users={violators} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Lock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { login } from "../../shared/api/client";
|
||||
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);
|
||||
localStorage.setItem("admin-password", password);
|
||||
onAuthenticated();
|
||||
} catch {
|
||||
setError("Invalid password");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Lock className="h-6 w-6" />
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ActiveSpeaker } from "../../../shared/api/client";
|
||||
import { Skeleton } from "../../../shared/ui";
|
||||
|
||||
interface ActiveSpeakersProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
if (speakers.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No active speakers.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{speakers.map((s) => {
|
||||
// BUG 4 FIX: stable key — no index fallback
|
||||
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-background/60 p-3"
|
||||
>
|
||||
<img
|
||||
src={s.avatar}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||
<div className="text-xs text-emerald-300">Speaking</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveSpeakersSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface AudioVisualizerProps {
|
||||
levels: number[];
|
||||
}
|
||||
|
||||
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
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;
|
||||
|
||||
// Gradient color based on level
|
||||
const hue = 199 - level * 199;
|
||||
const saturation = 89;
|
||||
const lightness = 48 + level * 20;
|
||||
ctx.fillStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
|
||||
// Rounded bar
|
||||
const radius = barWidth * 0.3;
|
||||
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 className="relative w-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={512}
|
||||
height={128}
|
||||
className="w-full rounded-xl bg-muted/30"
|
||||
style={{ height: "128px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Music2, SkipForward, Square, Volume2 } from "lucide-react";
|
||||
import { useEffect, 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));
|
||||
|
||||
// Debounced volume — poll every 200ms instead of instant send to avoid flood
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const normalized = draftVolume / 100;
|
||||
if (Math.abs(normalized - safeVolume) >= 0.001)
|
||||
onVolumeChange(normalized);
|
||||
}, 200);
|
||||
return () => clearInterval(id);
|
||||
}, [draftVolume, safeVolume, onVolumeChange]);
|
||||
|
||||
const submit = () => {
|
||||
const t = source.trim();
|
||||
if (!t) return;
|
||||
onQueue(t);
|
||||
setSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={draftVolume}
|
||||
onChange={(e) => setDraftVolume(Number(e.target.value))}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MonitorUp, Music2 } from "lucide-react";
|
||||
import type { MediaItem } from "../../../shared/api/client";
|
||||
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-2xl 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-xl bg-primary/15 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 border-border bg-background/60 p-2.5 text-sm"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// ─── Recordings Sub-Panel — BUG 1 FIX: useEffect instead of useMemo for side effects ──
|
||||
|
||||
import { Download, Mic } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
|
||||
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;
|
||||
created_at: number;
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
function formatDate(value: number): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function RecordingsSubPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// BUG 1 FIX: proper useEffect for async data fetching
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadRecordings() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch("/api/recordings");
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to load recordings: ${response.status}`);
|
||||
const data = (await response.json()) as VoiceRecording[];
|
||||
if (!cancelled) setRecordings(data);
|
||||
} catch (err) {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadRecordings();
|
||||
const handler = () => loadRecordings();
|
||||
window.addEventListener("voice_recording_uploaded", handler);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("voice_recording_uploaded", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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-background/60 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={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (recordings.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No recordings found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<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}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Headphones, Radio } from "lucide-react";
|
||||
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
|
||||
import { Button, Select } from "../../../shared/ui";
|
||||
|
||||
interface VoiceConnectionCardProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
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,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onJoin,
|
||||
onDisconnect,
|
||||
onListenToggle,
|
||||
onStreamingToggle,
|
||||
}: VoiceConnectionCardProps) {
|
||||
return (
|
||||
<div className="rounded-2xl 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" /> 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">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">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}
|
||||
>
|
||||
{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 ? "destructive" : "default"}
|
||||
onClick={onStreamingToggle}
|
||||
>
|
||||
<Radio className="mr-1.5 h-4 w-4" />{" "}
|
||||
{isStreaming ? "Stop Transmit" : "Transmit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// ─── Live feature barrel export ─────────────────────────────────────────────
|
||||
|
||||
export { ActiveSpeakers } from "./ActiveSpeakers";
|
||||
export { AudioVisualizer } from "./AudioVisualizer";
|
||||
export { MusicSubPanel } from "./MusicSubPanel";
|
||||
export { NowPlaying } from "./NowPlaying";
|
||||
export { RecordingsSubPanel } from "./RecordingsSubPanel";
|
||||
export { ScreenSubPanel } from "./ScreenSubPanel";
|
||||
export { VoiceConnectionCard } from "./VoiceConnectionCard";
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { MediaState } from "../../../shared/api/client";
|
||||
import {
|
||||
getMediaStatus,
|
||||
queueMedia,
|
||||
setMediaVolume,
|
||||
skipMedia,
|
||||
stopMedia,
|
||||
} from "../../../shared/api/client";
|
||||
|
||||
const emptyMediaState: MediaState = {
|
||||
playing: false,
|
||||
musicVolume: 1,
|
||||
current: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
export function useMediaControl() {
|
||||
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshMedia = useCallback(async () => {
|
||||
const state = await getMediaStatus();
|
||||
setMediaState(state);
|
||||
return state;
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
async (source: string, mode: "music" | "screen") => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const state = await queueMedia(source, mode);
|
||||
setMediaState(state);
|
||||
return state;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const skip = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const state = await skipMedia();
|
||||
setMediaState(state);
|
||||
return state;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const state = await stopMedia();
|
||||
setMediaState(state);
|
||||
return state;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback(async (volume: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
const state = await setMediaVolume(volume);
|
||||
setMediaState(state);
|
||||
return state;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshMedia().catch((err) =>
|
||||
setError(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}, [refreshMedia]);
|
||||
|
||||
return {
|
||||
mediaState,
|
||||
setMediaState,
|
||||
loading,
|
||||
error,
|
||||
refreshMedia,
|
||||
enqueue,
|
||||
skip,
|
||||
stop,
|
||||
setVolume,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
|
||||
import {
|
||||
connectVoice,
|
||||
disconnectVoice,
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
getVoiceStatus,
|
||||
} from "../../../shared/api/client";
|
||||
|
||||
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,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshGuilds = useCallback(async () => {
|
||||
setError(null);
|
||||
const nextGuilds = await getGuilds();
|
||||
setGuilds(nextGuilds);
|
||||
return nextGuilds;
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await connectVoice(guildId, channelId);
|
||||
setVoiceStatus(status);
|
||||
return status;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const leaveVoice = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await disconnectVoice();
|
||||
setVoiceStatus(status);
|
||||
return status;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshGuilds().catch((err) =>
|
||||
setError(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
refreshVoiceStatus().catch((err) =>
|
||||
setError(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}, [refreshGuilds, refreshVoiceStatus]);
|
||||
|
||||
return {
|
||||
guilds,
|
||||
voiceChannels,
|
||||
textChannels,
|
||||
voiceStatus,
|
||||
loading,
|
||||
error,
|
||||
refreshGuilds,
|
||||
refreshVoiceStatus,
|
||||
loadVoiceChannels,
|
||||
loadTextTargets,
|
||||
joinVoice,
|
||||
leaveVoice,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// ─── Live Panel — thin composition layer ────────────────────────────────────
|
||||
|
||||
import { Mic, MonitorUp, Music2 } from "lucide-react";
|
||||
import type {
|
||||
ActiveSpeaker,
|
||||
Channel,
|
||||
Guild,
|
||||
MediaState,
|
||||
VoiceStatus,
|
||||
} from "../../shared/api/client";
|
||||
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;
|
||||
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,
|
||||
mediaState,
|
||||
mediaLoading,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onJoin,
|
||||
onDisconnect,
|
||||
onListenToggle,
|
||||
onStreamingToggle,
|
||||
onQueueMusic,
|
||||
onStartScreen,
|
||||
onSkip,
|
||||
onStop,
|
||||
onVolumeChange,
|
||||
}: LivePanelProps) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<VoiceConnectionCard
|
||||
guilds={guilds}
|
||||
voiceChannels={voiceChannels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
status={status}
|
||||
voiceLoading={voiceLoading}
|
||||
isListening={isListening}
|
||||
isStreaming={isStreaming}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onJoin={onJoin}
|
||||
onDisconnect={onDisconnect}
|
||||
onListenToggle={onListenToggle}
|
||||
onStreamingToggle={onStreamingToggle}
|
||||
/>
|
||||
|
||||
<div 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>
|
||||
</div>
|
||||
|
||||
<NowPlaying current={mediaState.current} queue={mediaState.queue} />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
|
||||
interface MessageMetadata {
|
||||
stickers?: Array<{ name?: string; url?: string }>;
|
||||
attachments?: Array<{ name: string; url: string; contentType?: string }>;
|
||||
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
|
||||
}
|
||||
|
||||
interface ImageItem {
|
||||
url: string;
|
||||
title: string;
|
||||
kind: "attachment" | "embed" | "sticker";
|
||||
message: MessageRecord;
|
||||
}
|
||||
|
||||
function parseMetadata(value: string | null): MessageMetadata {
|
||||
if (!value) return {};
|
||||
try {
|
||||
return JSON.parse(value) as MessageMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
No images found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{images.map((image, index) => {
|
||||
// Stable key using message.id + url
|
||||
const stableKey = `${image.message.id}-${image.kind}-${index}`;
|
||||
return (
|
||||
<a
|
||||
key={stableKey}
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all hover:border-primary/30 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"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white backdrop-blur">
|
||||
{image.kind}
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Image as ImageIcon,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Smile,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
|
||||
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||
|
||||
/**
|
||||
* Renders message content with Discord custom emojis displayed as images
|
||||
* instead of raw text like `<:name:id>`.
|
||||
*/
|
||||
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) {
|
||||
// Text before the emoji
|
||||
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;
|
||||
}
|
||||
|
||||
// Remaining text after last emoji
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex));
|
||||
}
|
||||
|
||||
// If no emojis were found, just return the raw content
|
||||
if (parts.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <Fragment>{parts}</Fragment>;
|
||||
}
|
||||
|
||||
interface MessageCardProps {
|
||||
message: MessageRecord;
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface MessageMetadata {
|
||||
stickers?: Array<{ name?: string; url?: string }>;
|
||||
attachments?: Array<{ name: string; url: string; contentType?: string }>;
|
||||
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
|
||||
}
|
||||
|
||||
function parseMetadata(value: string | null): MessageMetadata {
|
||||
if (!value) return {};
|
||||
try {
|
||||
return JSON.parse(value) as MessageMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
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 aiVariant(status: string) {
|
||||
if (status === "clean") return "success";
|
||||
if (status === "warn") return "warning";
|
||||
if (status === "flagged" || status === "error") return "destructive";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function severityColor(severity: string) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "bg-red-500/20 text-red-300 border-red-500/30";
|
||||
case "high":
|
||||
return "bg-orange-500/20 text-orange-300 border-orange-500/30";
|
||||
case "medium":
|
||||
return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
|
||||
case "low":
|
||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
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();
|
||||
}
|
||||
|
||||
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
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);
|
||||
|
||||
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 hasImages = imageAttachments.length > 0;
|
||||
|
||||
const handleReanalyze = async () => {
|
||||
setIsReanalyzing(true);
|
||||
try {
|
||||
await onReanalyze(message.id);
|
||||
} finally {
|
||||
setIsReanalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group rounded-2xl border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={
|
||||
message.avatar_url ??
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||
}
|
||||
alt=""
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-semibold text-foreground">
|
||||
{message.username || message.user_id}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title={new Date(message.created_at).toLocaleString()}
|
||||
>
|
||||
{formatTimeAgo(message.created_at)}
|
||||
</span>
|
||||
{message.edited_at && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" /> edited
|
||||
</span>
|
||||
)}
|
||||
{message.deleted_at && (
|
||||
<span className="flex items-center gap-1 text-xs text-destructive">
|
||||
<Trash2 className="h-3 w-3" /> deleted
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant={aiVariant(aiStatus)}
|
||||
className="flex items-center gap-1 text-xs"
|
||||
>
|
||||
{aiStatus === "clean" && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "warn" && (
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "flagged" && (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus === "error" && (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{aiStatus}
|
||||
</Badge>
|
||||
{message.ai_severity && message.ai_severity !== "none" && (
|
||||
<Badge
|
||||
className={`text-xs ${severityColor(message.ai_severity)}`}
|
||||
>
|
||||
{message.ai_severity}
|
||||
</Badge>
|
||||
)}
|
||||
{confidence != null && (
|
||||
<Badge variant="outline" className="text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayContent ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||
{renderContentWithCustomEmojis(displayContent)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{stickers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{stickers.map((sticker) => (
|
||||
<div
|
||||
key={sticker.name || sticker.url}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{sticker.url ? (
|
||||
<img
|
||||
src={sticker.url}
|
||||
alt={sticker.name || "sticker"}
|
||||
className="h-16 w-16 rounded-xl border border-border object-contain bg-muted/50"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-xl border border-border bg-muted/50">
|
||||
<Smile className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className="text-xs text-muted-foreground max-w-[120px] truncate"
|
||||
title={sticker.name}
|
||||
>
|
||||
{sticker.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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-xl border border-border"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.name}
|
||||
className="h-20 w-20 object-cover transition-transform hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
{imageAttachments.length > 4 && (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-xl border border-border bg-muted text-xs text-muted-foreground">
|
||||
+{imageAttachments.length - 4}{" "}
|
||||
<ImageIcon className="ml-1 h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{categories.map((category) => (
|
||||
<Badge key={category} variant="secondary" className="text-xs">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.ai_analysis ? (
|
||||
<div className="rounded-xl bg-muted/60 p-3 text-sm text-muted-foreground leading-relaxed">
|
||||
{message.ai_analysis}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{message.ai_error ? (
|
||||
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
|
||||
AI error: {message.ai_error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={aiStatus === "error" ? "destructive" : "outline"}
|
||||
onClick={handleReanalyze}
|
||||
disabled={aiStatus === "pending" || isReanalyzing}
|
||||
className="text-xs"
|
||||
>
|
||||
<RotateCw
|
||||
className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||
</Button>
|
||||
{aiStatus === "error" && (
|
||||
<span className="text-xs text-destructive/80">
|
||||
Click to retry analysis
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageCardSkeleton() {
|
||||
return (
|
||||
<article className="rounded-2xl 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import { ScrollArea } from "../../../shared/ui";
|
||||
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;
|
||||
}
|
||||
|
||||
export function MessageFeed({
|
||||
messages,
|
||||
onReanalyze,
|
||||
emptyText = "No messages found.",
|
||||
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" }, // preload before user reaches bottom
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [onLoadMore, hasMore]);
|
||||
|
||||
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 (
|
||||
<div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<MessageCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
onReanalyze={onReanalyze}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import { listMessages, reanalyzeMessage } from "../../../shared/api/client";
|
||||
|
||||
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 });
|
||||
}
|
||||
// Removed .slice(0, 200) cap — let the message list grow unbounded.
|
||||
// Infinite scroll handles the data volume via cursor pagination.
|
||||
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 currentChannel = useRef<string | null>(null);
|
||||
|
||||
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||
if (!channelId) {
|
||||
setMessages([]);
|
||||
setCursor(null);
|
||||
setHasMore(false);
|
||||
return [];
|
||||
}
|
||||
currentChannel.current = channelId;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
channelId,
|
||||
});
|
||||
const result = await listMessages(params);
|
||||
// Only update state if we're still on the same channel (avoid race conditions)
|
||||
if (currentChannel.current === channelId) {
|
||||
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);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor || !currentChannel.current || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
channelId: currentChannel.current,
|
||||
cursor,
|
||||
});
|
||||
const result = await listMessages(params);
|
||||
// Only update if still on the same channel
|
||||
if (
|
||||
currentChannel.current === result.data[0]?.channel_id ||
|
||||
currentChannel.current
|
||||
) {
|
||||
setMessages((prev) => [...prev, ...result.data]);
|
||||
setCursor(result.nextCursor);
|
||||
setHasMore(!!result.nextCursor);
|
||||
}
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [cursor, loadingMore]);
|
||||
|
||||
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
|
||||
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === id
|
||||
? {
|
||||
...message,
|
||||
ai_status: "pending" as const,
|
||||
ai_error: null,
|
||||
ai_analysis: null,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
await reanalyzeMessage(id);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
setMessages,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
fetchMessages,
|
||||
reanalyze,
|
||||
loadMore,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Filter, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Channel, Guild, MessageRecord } from "../../shared/api/client";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Select,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../shared/ui";
|
||||
import { ImageGrid } from "./components/ImageGrid";
|
||||
import { MessageFeed } from "./components/MessageFeed";
|
||||
|
||||
interface MessagesPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
messages: MessageRecord[];
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
}
|
||||
|
||||
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
||||
|
||||
export function MessagesPanel({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
messages,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onReanalyze,
|
||||
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>("all");
|
||||
const [viewTab, setViewTab] = useState<"all" | "images">("all");
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
setShowSearch(false);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: searchQuery,
|
||||
...(selectedChannel && { channelId: selectedChannel }),
|
||||
limit: "50",
|
||||
});
|
||||
const response = await fetch(`/api/analysis/search?${params}`);
|
||||
if (!response.ok) throw new Error("Search failed");
|
||||
const data = await response.json();
|
||||
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,
|
||||
warn: base.filter((m) => m.ai_status === "warn").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 === "pending")
|
||||
return status === "pending" || status === null || status === undefined;
|
||||
return status === aiFilter;
|
||||
});
|
||||
}, [messages, searchResults, showSearch, aiFilter]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Message Source</CardTitle>
|
||||
<CardDescription>
|
||||
Pick a guild and channel/thread to inspect captures.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(e) => onGuildChange(e.target.value)}
|
||||
placeholder="Select text guild"
|
||||
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||
/>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => onChannelChange(e.target.value)}
|
||||
placeholder="Select channel or thread"
|
||||
options={channels.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{stats.total > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{stats.total} total{hasMore && !showSearch ? "+" : ""}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-green-400 border-green-400/30"
|
||||
>
|
||||
{stats.clean} clean
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-yellow-400 border-yellow-400/30"
|
||||
>
|
||||
{stats.warn} warn
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-red-400 border-red-400/30"
|
||||
>
|
||||
{stats.flagged} flagged
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-orange-400 border-orange-400/30"
|
||||
>
|
||||
{stats.error} error
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stats.pending} pending
|
||||
</Badge>
|
||||
{stats.deleted > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{stats.deleted} deleted
|
||||
</Badge>
|
||||
)}
|
||||
{stats.edited > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stats.edited} edited
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div 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-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
{(
|
||||
[
|
||||
"all",
|
||||
"clean",
|
||||
"warn",
|
||||
"flagged",
|
||||
"error",
|
||||
"pending",
|
||||
] as AiFilter[]
|
||||
).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setAiFilter(f)}
|
||||
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${aiFilter === f ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSearch && searchResults.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Found {searchResults.length} result
|
||||
{searchResults.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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."
|
||||
: selectedChannel
|
||||
? "No captures yet."
|
||||
: "Select a channel to view captures."
|
||||
}
|
||||
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||
hasMore={showSearch ? false : hasMore}
|
||||
loadingMore={loadingMore}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="images">
|
||||
<ImageGrid messages={filteredMessages} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000, // data stays fresh for 30s — no refetch within this window
|
||||
gcTime: 5 * 60_000, // keep unused data in cache for 5 minutes
|
||||
refetchOnWindowFocus: false, // avoid spamming the API on tab switches
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,443 @@
|
||||
// ─── Shared HTTP client — all API endpoints in one file ──────────────────────
|
||||
|
||||
const BE_API_URL = import.meta.env.VITE_BE_API_URL || "http://localhost:3001";
|
||||
const BE_WS_URL = import.meta.env.VITE_BE_WS_URL || "ws://localhost:3001";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const password = localStorage.getItem("admin-password");
|
||||
const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(password ? { "X-Admin-Password": password } : {}),
|
||||
},
|
||||
...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
|
||||
}
|
||||
throw new ApiError(code, message, res.status);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function getWebSocketURL(): string {
|
||||
return BE_WS_URL;
|
||||
}
|
||||
|
||||
export function getAPIURL(): string {
|
||||
return BE_API_URL;
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PageResult<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
edited_content: string | null;
|
||||
created_at: number;
|
||||
edited_at: number | null;
|
||||
deleted_at: number | null;
|
||||
type: "text" | "edited" | "deleted";
|
||||
metadata: string | null;
|
||||
ai_status?: string | null;
|
||||
ai_moderation_flags?: string | null;
|
||||
ai_moderation_score?: number | null;
|
||||
ai_analysis?: string | null;
|
||||
ai_categories?: string | null;
|
||||
ai_severity?: string | null;
|
||||
ai_confidence?: number | null;
|
||||
ai_recommended_action?: string | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export interface VoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId?: string | null;
|
||||
activeChannelId?: string | null;
|
||||
activeChannelName?: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveSpeaker {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
speaking: boolean;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id?: string;
|
||||
source: string;
|
||||
title: string;
|
||||
mode?: "music" | "screen";
|
||||
durationMs?: number | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
selectedGuild?: string;
|
||||
selectedVoiceGuild?: string;
|
||||
selectedVoiceChannel?: string;
|
||||
selectedTextGuild?: string;
|
||||
selectedTextChannel?: string;
|
||||
selectedAnalyticsGuild?: string;
|
||||
selectedAnalyticsChannel?: string;
|
||||
activeTab?: "live" | "messages" | "analytics";
|
||||
isListening?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
monitorGuildId: string | null;
|
||||
}
|
||||
|
||||
export type DashboardTab = "live" | "messages" | "analytics";
|
||||
|
||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function listMessages(
|
||||
params: URLSearchParams,
|
||||
): Promise<PageResult<MessageRecord>> {
|
||||
return request<PageResult<MessageRecord>>(`/api/messages?${params}`);
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
// ─── 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/status");
|
||||
}
|
||||
|
||||
export function connectVoice(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
): Promise<VoiceStatus> {
|
||||
return request<VoiceStatus>("/api/connect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ guildId, channelId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnectVoice(): Promise<VoiceStatus> {
|
||||
return request<VoiceStatus>("/api/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 }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function login(password: string): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Analytics ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HourlyBucket {
|
||||
hour: string;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface TopicTrend {
|
||||
topic: string;
|
||||
count: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface UserStat {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
message_count: number;
|
||||
edited_count: number;
|
||||
deleted_count: number;
|
||||
flagged_count: number;
|
||||
last_active: number;
|
||||
}
|
||||
|
||||
export interface ModerationBreakdown {
|
||||
total: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
pending: number;
|
||||
average_score: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsOverview {
|
||||
period: { start: number; end: number };
|
||||
messages: ModerationBreakdown;
|
||||
hourly: HourlyBucket[];
|
||||
topics: TopicTrend[];
|
||||
top_users: UserStat[];
|
||||
active_users_count: number;
|
||||
total_channels: number;
|
||||
}
|
||||
|
||||
export interface ViolatorStat {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
warned_count: number;
|
||||
violation_score: number;
|
||||
worst_flags: string[];
|
||||
last_violation: number;
|
||||
}
|
||||
|
||||
export interface TrendBucket {
|
||||
date: string;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
dayOfWeek: number;
|
||||
hour: number;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
}
|
||||
|
||||
export function fetchAnalyticsOverview(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<AnalyticsOverview> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<AnalyticsOverview>(`/api/analytics/overview?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchHourlyStats(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<HourlyBucket[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<HourlyBucket[]>(`/api/analytics/hourly?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchTopicTrends(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<TopicTrend[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<TopicTrend[]>(`/api/analytics/topics?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchLeaderboard(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<UserStat[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
...(params.limit && { limit: String(params.limit) }),
|
||||
});
|
||||
return request<UserStat[]>(`/api/analytics/leaderboard?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchModerationStats(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<ModerationBreakdown> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<ModerationBreakdown>(`/api/analytics/stats?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchViolators(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<ViolatorStat[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
...(params.limit && { limit: String(params.limit) }),
|
||||
});
|
||||
return request<ViolatorStat[]>(`/api/analytics/violators?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchTrend(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<TrendBucket[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<TrendBucket[]>(`/api/analytics/trend?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchHeatmap(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<HeatmapCell[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<HeatmapCell[]>(`/api/analytics/heatmap?${sp}`);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ──
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
const SAMPLE_RATE = 24000;
|
||||
const CHANNELS = 1;
|
||||
|
||||
export function useAudioPlayback() {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [levels, setLevels] = useState<number[]>(
|
||||
Array.from({ length: 32 }, () => 0.04),
|
||||
);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const userTimelinesRef = useRef(new Map<number, number>());
|
||||
|
||||
const handleIncomingPcm = useCallback(
|
||||
(data: ArrayBuffer) => {
|
||||
const headerView = new DataView(data, 0, 4);
|
||||
const userIdHash = headerView.getInt32(0, true);
|
||||
const audioData = data.slice(4);
|
||||
const int16Array = new Int16Array(audioData);
|
||||
let sum = 0;
|
||||
for (const sample of int16Array) sum += Math.abs(sample / 32768);
|
||||
const average = int16Array.length ? sum / int16Array.length : 0;
|
||||
setLevels((prev) =>
|
||||
prev.map((_, index) =>
|
||||
Math.max(
|
||||
0.04,
|
||||
average *
|
||||
(0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) *
|
||||
5,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const audioContext = audioContextRef.current;
|
||||
if (!isListening || !audioContext) return;
|
||||
const float32Array = new Float32Array(int16Array.length);
|
||||
for (let i = 0; i < int16Array.length; i++)
|
||||
float32Array[i] = int16Array[i] / 32768;
|
||||
const audioBuffer = audioContext.createBuffer(
|
||||
CHANNELS,
|
||||
float32Array.length / SAMPLE_RATE,
|
||||
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(userIdHash) || 0;
|
||||
if (nextStart < currentTime) nextStart = currentTime + 0.05;
|
||||
source.start(nextStart);
|
||||
userTimelinesRef.current.set(
|
||||
userIdHash,
|
||||
nextStart + audioBuffer.duration,
|
||||
);
|
||||
},
|
||||
[isListening],
|
||||
);
|
||||
|
||||
const toggleListening = useCallback(async () => {
|
||||
if (isListening) {
|
||||
await audioContextRef.current?.suspend();
|
||||
userTimelinesRef.current.clear();
|
||||
setIsListening(false);
|
||||
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);
|
||||
}, [isListening]);
|
||||
|
||||
return {
|
||||
isListening,
|
||||
levels,
|
||||
handleIncomingPcm,
|
||||
toggleListening,
|
||||
audioContextRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ──
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
const SAMPLE_RATE = 24000;
|
||||
|
||||
export function useAudioTransmit(socketRef: {
|
||||
readonly current: WebSocket | null;
|
||||
}) {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
setIsStreaming(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);
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
processorRef.current = processor;
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN)
|
||||
return;
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
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;
|
||||
// BUG 2 FIX: slice() to create independent copy of the ArrayBuffer
|
||||
socketRef.current.send(pcmData.buffer.slice(0));
|
||||
};
|
||||
}, [socketRef]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (isStreaming) stop();
|
||||
else await start();
|
||||
}, [isStreaming, start, stop]);
|
||||
|
||||
return { isStreaming, toggle, stop, start };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// ─── 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: "live" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCallback } from "react";
|
||||
import type { UIState } from "../../entities/ui/types";
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BarChart3, MessageSquare, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../../entities/ui/types";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
|
||||
{ id: "live", label: "Live", Icon: Radio },
|
||||
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
||||
{ id: "analytics", label: "Analytics", Icon: BarChart3 },
|
||||
];
|
||||
|
||||
interface MobileTabBarProps {
|
||||
activeTab: DashboardTab;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
}
|
||||
|
||||
export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-background/90 backdrop-blur-xl md:hidden">
|
||||
{tabs.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onTabChange(id)}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
|
||||
activeTab === id ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type BadgeVariant =
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "success"
|
||||
| "warning";
|
||||
|
||||
const variants: Record<BadgeVariant, string> = {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-emerald-500/15 text-emerald-300",
|
||||
warning: "border-transparent bg-amber-500/15 text-amber-300",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type ButtonVariant =
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "ghost";
|
||||
type ButtonSize = "default" | "sm" | "lg" | "icon";
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
const sizes: Record<ButtonSize, string> = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
};
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// ─── Shared UI barrel export ────────────────────────────────────────────────
|
||||
|
||||
export { Badge } from "./badge";
|
||||
export { Button } from "./button";
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./card";
|
||||
export { Input } from "./input";
|
||||
export { ScrollArea } from "./scroll-area";
|
||||
export { Select } from "./select";
|
||||
export { Skeleton } from "./skeleton";
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
|
||||
export { ToastProvider, useToast } from "./toast";
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export function Input({ className, type, ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<
|
||||
typeof ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps
|
||||
extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
className,
|
||||
options,
|
||||
placeholder,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted/60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export const Tabs = TabsPrimitive.Root;
|
||||
|
||||
export function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
className={cn(
|
||||
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// ─── Toast notification system ──────────────────────────────────────────────
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: "info" | "success" | "error" | "warning";
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: Toast["type"]) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType>({
|
||||
toasts: [],
|
||||
addToast: () => {},
|
||||
removeToast: () => {},
|
||||
});
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback(
|
||||
(message: string, type: Toast["type"] = "info") => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(
|
||||
() => setToasts((prev) => prev.filter((t) => t.id !== id)),
|
||||
4000,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
function ToastContainer() {
|
||||
const { toasts, removeToast } = useContext(ToastContext);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur-xl cursor-pointer transition-all hover:scale-[1.02] ${
|
||||
toast.type === "error"
|
||||
? "border-destructive/30 bg-destructive/20 text-destructive"
|
||||
: toast.type === "success"
|
||||
? "border-green-500/30 bg-green-500/10 text-green-300"
|
||||
: toast.type === "warning"
|
||||
? "border-yellow-500/30 bg-yellow-500/10 text-yellow-300"
|
||||
: "border-border/30 bg-card/80 text-foreground"
|
||||
}`}
|
||||
onClick={() => removeToast(toast.id)}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// ─── Typed event map for WebSocket events ────────────────────────────────────
|
||||
|
||||
export interface WsEventMap {
|
||||
message_created: { data: unknown };
|
||||
message_updated: { data: unknown };
|
||||
message_deleted: { data: { id: string } };
|
||||
message_analyzed: { data: unknown };
|
||||
attachment_uploaded: Record<string, never>;
|
||||
user_state: { users: unknown[] };
|
||||
ui_state: { state: unknown };
|
||||
media_state: { state: unknown };
|
||||
voice_recording_uploaded: { data: unknown };
|
||||
}
|
||||
|
||||
export type WsEventType = keyof WsEventMap;
|
||||
|
||||
export function parseWsMessage(
|
||||
raw: string,
|
||||
): { type: WsEventType; payload: Record<string, unknown> } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed.type) return null;
|
||||
const { type, ...rest } = parsed;
|
||||
return { type: type as WsEventType, payload: rest };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// ─── WebSocket singleton with reconnect, typed events, and observable status ─
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type WsStatus = "connecting" | "connected" | "disconnected" | "error";
|
||||
|
||||
export type BinaryHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
export interface WsHandlers {
|
||||
onBinary?: BinaryHandler;
|
||||
onMessageCreated?: (data: unknown) => void;
|
||||
onMessageUpdated?: (data: unknown) => void;
|
||||
onMessageDeleted?: (data: unknown) => void;
|
||||
onMessageAnalyzed?: (data: unknown) => void;
|
||||
onAttachmentUploaded?: () => void;
|
||||
onUserState?: (users: unknown[]) => void;
|
||||
onUiState?: (state: unknown) => void;
|
||||
onMediaState?: (state: unknown) => void;
|
||||
onVoiceRecordingUploaded?: (data: unknown) => void;
|
||||
}
|
||||
|
||||
let _wsInstance: WebSocket | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _closed = false;
|
||||
const _listeners = new Set<WsHandlers>();
|
||||
const _statusCallbacks = new Set<(s: WsStatus) => void>();
|
||||
|
||||
function dispatchStatus(s: WsStatus): void {
|
||||
for (const cb of _statusCallbacks) cb(s);
|
||||
}
|
||||
|
||||
function doConnect(): WebSocket {
|
||||
const BE_WS_URL =
|
||||
import.meta.env.VITE_BE_WS_URL ||
|
||||
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
|
||||
const url = BE_WS_URL.endsWith("/ws") ? BE_WS_URL : `${BE_WS_URL}/ws`;
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
dispatchStatus("connecting");
|
||||
|
||||
ws.addEventListener("open", () => dispatchStatus("connected"));
|
||||
ws.addEventListener("error", () => dispatchStatus("error"));
|
||||
ws.addEventListener("close", () => {
|
||||
dispatchStatus("disconnected");
|
||||
if (!_closed && _listeners.size > 0) {
|
||||
_reconnectTimer = setTimeout(() => doReconnect(), 2500);
|
||||
}
|
||||
});
|
||||
ws.addEventListener("message", (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
for (const h of _listeners) h.onBinary?.(event.data);
|
||||
return;
|
||||
}
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as Record<string, unknown>;
|
||||
for (const h of _listeners) {
|
||||
switch (msg.type) {
|
||||
case "message_created":
|
||||
h.onMessageCreated?.(msg.data);
|
||||
break;
|
||||
case "message_updated":
|
||||
h.onMessageUpdated?.(msg.data);
|
||||
break;
|
||||
case "message_deleted":
|
||||
h.onMessageDeleted?.(msg.data);
|
||||
break;
|
||||
case "message_analyzed":
|
||||
h.onMessageAnalyzed?.(msg.data);
|
||||
break;
|
||||
case "attachment_uploaded":
|
||||
h.onAttachmentUploaded?.();
|
||||
break;
|
||||
case "user_state":
|
||||
h.onUserState?.((msg.users as unknown[]) || []);
|
||||
break;
|
||||
case "ui_state":
|
||||
h.onUiState?.(msg.state);
|
||||
break;
|
||||
case "media_state":
|
||||
h.onMediaState?.(msg.state);
|
||||
break;
|
||||
case "voice_recording_uploaded":
|
||||
h.onVoiceRecordingUploaded?.(msg.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
});
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function doReconnect(): void {
|
||||
if (_wsInstance) {
|
||||
_wsInstance.close();
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
}
|
||||
_closed = false;
|
||||
_wsInstance = doConnect();
|
||||
}
|
||||
|
||||
function ensureConnected(): void {
|
||||
if (!_wsInstance || _wsInstance.readyState === WebSocket.CLOSED) {
|
||||
if (_wsInstance) {
|
||||
_wsInstance.close();
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
}
|
||||
_closed = false;
|
||||
_wsInstance = doConnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function useDashboardSocket(handlers: WsHandlers) {
|
||||
const [status, setStatus] = useState<WsStatus>("connecting");
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper: WsHandlers = {
|
||||
onBinary: (d) => handlersRef.current.onBinary?.(d),
|
||||
onMessageCreated: (d) => handlersRef.current.onMessageCreated?.(d),
|
||||
onMessageUpdated: (d) => handlersRef.current.onMessageUpdated?.(d),
|
||||
onMessageDeleted: (d) => handlersRef.current.onMessageDeleted?.(d),
|
||||
onMessageAnalyzed: (d) => handlersRef.current.onMessageAnalyzed?.(d),
|
||||
onAttachmentUploaded: () => handlersRef.current.onAttachmentUploaded?.(),
|
||||
onUserState: (u) => handlersRef.current.onUserState?.(u),
|
||||
onUiState: (s) => handlersRef.current.onUiState?.(s),
|
||||
onMediaState: (s) => handlersRef.current.onMediaState?.(s),
|
||||
onVoiceRecordingUploaded: (d) =>
|
||||
handlersRef.current.onVoiceRecordingUploaded?.(d),
|
||||
};
|
||||
|
||||
_listeners.add(wrapper);
|
||||
_statusCallbacks.add(setStatus);
|
||||
|
||||
if (_listeners.size === 1) {
|
||||
ensureConnected();
|
||||
}
|
||||
|
||||
return () => {
|
||||
_listeners.delete(wrapper);
|
||||
_statusCallbacks.delete(setStatus);
|
||||
if (_listeners.size === 0) {
|
||||
_closed = true;
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
_wsInstance?.close();
|
||||
_wsInstance = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const send = useCallback((data: ArrayBuffer | string) => {
|
||||
if (_wsInstance?.readyState === WebSocket.OPEN) {
|
||||
_wsInstance.send(data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { status, send, socketRef: { current: _wsInstance } };
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
export { useDashboardSocket as useWsSocket };
|
||||
@@ -0,0 +1,79 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 222 47% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 8%;
|
||||
--card-glass: 222 47% 12% / 0.6;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 199 89% 52%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 217 33% 15%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217 33% 15%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 217 33% 20%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 72% 55%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217 33% 20% / 0.5;
|
||||
--input: 217 33% 20%;
|
||||
--ring: 199 89% 52%;
|
||||
--radius: 0.85rem;
|
||||
--glow: 199 89% 52% / 0.15;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
/* Mesh gradient background */
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 50%, hsl(260 50% 15% / 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 85% 30%, hsl(var(--primary) / 0.1) 0%, transparent 50%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass {
|
||||
background-color: hsl(var(--card-glass));
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glow {
|
||||
box-shadow: 0 0 20px hsl(var(--glow));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bar-pulse {
|
||||
0%, 100% {
|
||||
transform: scaleY(0.8);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bar-pulse {
|
||||
animation: bar-pulse 0.4s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { DashboardTab } from "../entities/ui/types";
|
||||
import type { VoiceStatus } from "../shared/api/client";
|
||||
import type { WsStatus } from "../shared/ws/socket";
|
||||
import { Header } from "./Header";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
activeTab: DashboardTab;
|
||||
wsStatus: WsStatus;
|
||||
voiceStatus: VoiceStatus;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
activeTab,
|
||||
wsStatus,
|
||||
voiceStatus,
|
||||
onTabChange,
|
||||
children,
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar activeTab={activeTab} onTabChange={onTabChange} />
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<Header
|
||||
activeTab={activeTab}
|
||||
wsStatus={wsStatus}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import type { DashboardTab } from "../entities/ui/types";
|
||||
import type { VoiceStatus } from "../shared/api/client";
|
||||
import { Badge } from "../shared/ui";
|
||||
import type { WsStatus } from "../shared/ws/socket";
|
||||
|
||||
const titles: Record<DashboardTab, string> = {
|
||||
live: "Voice, Media & Recordings",
|
||||
messages: "Messages & Moderation",
|
||||
analytics: "Analytics & Insights",
|
||||
};
|
||||
|
||||
const subtitles: Record<DashboardTab, string> = {
|
||||
live: "Join voice channels, play media, stream audio, and browse recordings.",
|
||||
messages: "Capture, analyse, and moderate Discord messages.",
|
||||
analytics: "Server moderation statistics and trends.",
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
activeTab: DashboardTab;
|
||||
wsStatus: WsStatus;
|
||||
voiceStatus: VoiceStatus;
|
||||
}
|
||||
|
||||
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 px-4 py-4 backdrop-blur md:px-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
<span className="text-primary">GMW</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
{titles[activeTab]}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{subtitles[activeTab]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
wsStatus === "connected"
|
||||
? "success"
|
||||
: wsStatus === "error"
|
||||
? "destructive"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{wsStatus === "connected" ? (
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
WebSocket {wsStatus}
|
||||
</Badge>
|
||||
<Badge variant={voiceStatus.connected ? "success" : "secondary"}>
|
||||
Voice{" "}
|
||||
{voiceStatus.connected
|
||||
? voiceStatus.activeChannelName || "connected"
|
||||
: "idle"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { BarChart3, MessageSquare, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../entities/ui/types";
|
||||
import { cn } from "../shared/lib/utils";
|
||||
import { Button } from "../shared/ui";
|
||||
|
||||
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> =
|
||||
[
|
||||
{ id: "live", label: "Live", icon: Radio },
|
||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||
{ id: "analytics", label: "Analytics", icon: BarChart3 },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: DashboardTab;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeTab, onTabChange, collapsed }: SidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden shrink-0 border-r border-border bg-card/60 p-5 backdrop-blur transition-all duration-300 md:block",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-8 flex items-center gap-3",
|
||||
collapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="GMW"
|
||||
className="h-10 w-10 rounded-2xl"
|
||||
/>
|
||||
<span className="font-bold tracking-tight text-primary text-lg">
|
||||
GMW
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Discord Moderation Watcher
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<img src="/logo.svg" alt="GMW" className="h-9 w-9 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={activeTab === item.id ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start",
|
||||
activeTab === item.id && "bg-primary/15 text-primary",
|
||||
collapsed && "justify-center px-0",
|
||||
)}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{!collapsed && item.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user