refactor(fe): align dashboard with backend & gateway data flow
- API/WS clients: same-origin by default, drop dead imphnen hardcode
- chatbot history: map BE rows {user_message,bot_response,created_at}
- message_deleted WS payload: object {id,deleted_at}, not bare string
- dashboard: wire top-channels chart + live mod queue from /api/review,
add Users & Channels tabs consuming /api/dashboard/users|channels
- recordings: live WS sync via voice_recording_uploaded; duration_bytes optional
- remove dead widgets with no BE data source (trend chart, heatmap)
This commit is contained in:
@@ -1,32 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Clock, Hash, Shield, Sparkles, Users } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Hash,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useStats } from "@/hooks";
|
||||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { ChannelsSection } from "@/components/dashboard/channels-section";
|
||||
import { LiveStream } from "@/components/dashboard/live-stream";
|
||||
import type { ModQueueItem } from "@/components/dashboard/mod-queue";
|
||||
import { ModQueue } from "@/components/dashboard/mod-queue";
|
||||
import { MessageTrendChart } from "@/components/dashboard/message-trend-chart";
|
||||
import { ActivityHeatmap } from "@/components/dashboard/activity-heatmap";
|
||||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
|
||||
import { UsersSection } from "@/components/dashboard/users-section";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useReview, useStats } from "@/hooks";
|
||||
|
||||
type DashboardTab = "stats" | "live" | "activity";
|
||||
type DashboardTab = "stats" | "live" | "users" | "channels";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [tab, setTab] = useState<DashboardTab>("stats");
|
||||
const { data: stats, isLoading, error, refetch } = useStats();
|
||||
const { data: review = [] } = useReview();
|
||||
|
||||
const modQueueItems: ModQueueItem[] = review.slice(0, 10).map((msg) => ({
|
||||
id: msg.id,
|
||||
content: msg.content || msg.id,
|
||||
username: msg.username,
|
||||
severity:
|
||||
msg.ai_severity && msg.ai_severity !== "none"
|
||||
? (msg.ai_severity as ModQueueItem["severity"])
|
||||
: "medium",
|
||||
reason: msg.ai_analysis ?? "AI moderation flag",
|
||||
}));
|
||||
|
||||
const subNavTabs = [
|
||||
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
|
||||
{ id: "live", label: "Live", icon: <Sparkles className="size-3" /> },
|
||||
{ id: "activity", label: "Activity", icon: <Clock className="size-3" /> },
|
||||
{ id: "users", label: "Users", icon: <Users className="size-3" /> },
|
||||
{ id: "channels", label: "Channels", icon: <Hash className="size-3" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
<SubNav tabs={subNavTabs} activeTab={tab} onTabChange={(t) => setTab(t as DashboardTab)} />
|
||||
<SubNav
|
||||
tabs={subNavTabs}
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as DashboardTab)}
|
||||
/>
|
||||
|
||||
{tab === "stats" && (
|
||||
<div className="space-y-4">
|
||||
@@ -37,18 +62,46 @@ export default function DashboardPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard label="Total Messages" value={stats.total_messages} icon={Hash} />
|
||||
<StatCard label="Today" value={stats.today_messages} icon={Clock} />
|
||||
<StatCard label="Users" value={stats.total_users} icon={Users} />
|
||||
<StatCard label="Active 24h" value={stats.active_users_24h} icon={Sparkles} />
|
||||
<StatCard label="Flagged" value={stats.total_flagged} icon={AlertCircle} variant="danger" />
|
||||
<StatCard label="Clean" value={stats.total_clean} icon={Shield} variant="success" />
|
||||
<StatCard
|
||||
label="Total Messages"
|
||||
value={stats.total_messages}
|
||||
icon={Hash}
|
||||
/>
|
||||
<StatCard
|
||||
label="Today"
|
||||
value={stats.today_messages}
|
||||
icon={Clock}
|
||||
/>
|
||||
<StatCard
|
||||
label="Users"
|
||||
value={stats.total_users}
|
||||
icon={Users}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active 24h"
|
||||
value={stats.active_users_24h}
|
||||
icon={Sparkles}
|
||||
/>
|
||||
<StatCard
|
||||
label="Flagged"
|
||||
value={stats.total_flagged}
|
||||
icon={AlertCircle}
|
||||
variant="danger"
|
||||
/>
|
||||
<StatCard
|
||||
label="Clean"
|
||||
value={stats.total_clean}
|
||||
icon={Shield}
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<MessageTrendChart />
|
||||
<TopChannelsChart />
|
||||
</div>
|
||||
<TopChannelsChart
|
||||
data={stats.top_channels.map((c) => ({
|
||||
name: c.channel_name ?? c.channel_id,
|
||||
count: c.message_count,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -57,15 +110,13 @@ export default function DashboardPage() {
|
||||
{tab === "live" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<LiveStream />
|
||||
<ModQueue />
|
||||
<ModQueue items={modQueueItems} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "activity" && (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<ActivityHeatmap />
|
||||
</div>
|
||||
)}
|
||||
{tab === "users" && <UsersSection />}
|
||||
|
||||
{tab === "channels" && <ChannelsSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { RecordingCard } from "@/components/recordings/recording-card";
|
||||
import { RecordingPlayer } from "@/components/recordings/recording-player";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useRecordings } from "@/hooks";
|
||||
import { useRecordings, useRecordingsWsSync } from "@/hooks";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
type RecordingsTab = "library" | "stats";
|
||||
|
||||
@@ -14,10 +15,15 @@ export default function RecordingsPage() {
|
||||
const { data: recordings, isLoading, error, refetch } = useRecordings();
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<RecordingsTab>("library");
|
||||
const ws = useWebSocket();
|
||||
|
||||
const currentTrack = playingId && recordings
|
||||
? recordings.find((r: VoiceRecording) => r.id === playingId)
|
||||
: null;
|
||||
// Live-update the library when the gateway publishes voice_recording_uploaded
|
||||
useRecordingsWsSync(ws);
|
||||
|
||||
const currentTrack =
|
||||
playingId && recordings
|
||||
? recordings.find((r: VoiceRecording) => r.id === playingId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
@@ -30,34 +36,38 @@ export default function RecordingsPage() {
|
||||
onTabChange={(t) => setTab(t as RecordingsTab)}
|
||||
/>
|
||||
|
||||
{tab === "library" && (
|
||||
<>
|
||||
{error ? (
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
) : isLoading ? (
|
||||
<LoadingSkeleton count={4} height="h-28" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(recordings ?? []).map((rec: VoiceRecording) => (
|
||||
<RecordingCard
|
||||
key={rec.id}
|
||||
recording={rec}
|
||||
onPlay={(id) => setPlayingId(id === playingId ? null : id)}
|
||||
/>
|
||||
))}
|
||||
{(recordings ?? []).length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-text-secondary/40">No recordings yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tab === "library" &&
|
||||
(error ? (
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
) : isLoading ? (
|
||||
<LoadingSkeleton count={4} height="h-28" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(recordings ?? []).map((rec: VoiceRecording) => (
|
||||
<RecordingCard
|
||||
key={rec.id}
|
||||
recording={rec}
|
||||
onPlay={(id) => setPlayingId(id === playingId ? null : id)}
|
||||
/>
|
||||
))}
|
||||
{(recordings ?? []).length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-text-secondary/40">
|
||||
No recordings yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tab === "stats" && (
|
||||
<div className="py-12 text-center text-sm text-text-secondary/40">Recording stats coming soon</div>
|
||||
<div className="py-12 text-center text-sm text-text-secondary/40">
|
||||
Recording stats coming soon
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecordingPlayer url={currentTrack?.download_url ?? undefined} onClose={() => setPlayingId(null)} />
|
||||
<RecordingPlayer
|
||||
url={currentTrack?.download_url ?? undefined}
|
||||
onClose={() => setPlayingId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user