refactor: remove Review tab, make UI state client-side, fix Jakarta time in analytics

- Remove Review tab completely (was redundant with Messages flagged view)
- UI state now client-side only (localStorage) — no server API calls for tab/channel/guild selection
- Fixes dashboard crash when server is down — now loads fully client-side
- Live panel still uses server API for voice/media operations (only what needs it)
- Analytics hourly chart labels now show Jakarta time (WIB/UTC+7) instead of UTC
- Analytics formatTimeAgo uses Jakarta time reference
- Reduced tabs to 3: Live, Messages, Analytics
- Removed unused uiState API imports and server-side state fetching

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-30 16:31:27 +07:00
co-authored by Claude Opus 4.8
parent 9019e24263
commit c7abe39728
6 changed files with 46 additions and 45 deletions
+5 -9
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DashboardLayout } from "./components/layout/DashboardLayout"; import { DashboardLayout } from "./components/layout/DashboardLayout";
import { LivePanel } from "./components/live/LivePanel"; import { LivePanel } from "./components/live/LivePanel";
import { MessagesPanel } from "./components/messages/MessagesPanel"; import { MessagesPanel } from "./components/messages/MessagesPanel";
import { ReviewPanel } from "./components/review/ReviewPanel";
import { Tabs, TabsContent } from "./components/ui/tabs"; import { Tabs, TabsContent } from "./components/ui/tabs";
import { AnalyticsPanel } from "./components/analytics/AnalyticsPanel"; import { AnalyticsPanel } from "./components/analytics/AnalyticsPanel";
import { AuthOverlay } from "./components/layout/AuthOverlay"; import { AuthOverlay } from "./components/layout/AuthOverlay";
@@ -122,8 +121,8 @@ export default function App() {
}, [socket.socketRef]); }, [socket.socketRef]);
const toggleStreaming = useCallback(async () => { const toggleStreaming = useCallback(async () => {
if (isStreaming) { stopStreamingLocal(); await patchUIState({ isStreaming: false }); } if (isStreaming) { stopStreamingLocal(); patchUIState({ isStreaming: false }); }
else { await startStreamingLocal(); await patchUIState({ isStreaming: true }); } else { await startStreamingLocal(); patchUIState({ isStreaming: true }); }
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]); }, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
useEffect(() => { if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); }, [selectedVoiceGuild]); useEffect(() => { if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); }, [selectedVoiceGuild]);
@@ -131,15 +130,15 @@ export default function App() {
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel]); useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel]);
const toggleListening = useCallback(async () => { const toggleListening = useCallback(async () => {
if (isListening) { await audioContextListenRef.current?.suspend(); userTimelinesRef.current.clear(); setIsListening(false); await patchUIState({ isListening: false }); return; } if (isListening) { await audioContextListenRef.current?.suspend(); userTimelinesRef.current.clear(); setIsListening(false); patchUIState({ isListening: false }); return; }
const AudioContextCtor = window.AudioContext || window.webkitAudioContext; const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE }); audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextListenRef.current.resume(); await audioContextListenRef.current.resume();
setIsListening(true); setIsListening(true);
await patchUIState({ isListening: true }); patchUIState({ isListening: true });
}, [isListening, patchUIState]); }, [isListening, patchUIState]);
const tabs = useMemo(() => ["live", "messages", "analytics", "review"] as DashboardTab[], []); const tabs = useMemo(() => ["live", "messages", "analytics"] as DashboardTab[], []);
return ( return (
<DashboardLayout <DashboardLayout
@@ -213,9 +212,6 @@ export default function App() {
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })} onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
/> />
</TabsContent> </TabsContent>
<TabsContent value="review">
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
</TabsContent>
</Tabs> </Tabs>
</DashboardLayout> </DashboardLayout>
); );
@@ -499,7 +499,12 @@ function HourlyChart({ hourly, loading }: { hourly: HourlyBucket[] | undefined;
} }
const maxCount = Math.max(...hourly.map((b) => b.count), 1); const maxCount = Math.max(...hourly.map((b) => b.count), 1);
const labels = hourly.map((b) => b.hour.slice(11, 16)); // Convert UTC hour buckets to Jakarta time (UTC+7)
const labels = hourly.map((b) => {
const utcHour = parseInt(b.hour.slice(11, 13), 10);
const jakartaHour = (utcHour + 7) % 24;
return `${String(jakartaHour).padStart(2, "0")}:00`;
});
return ( return (
<div ref={containerRef} className="space-y-3"> <div ref={containerRef} className="space-y-3">
@@ -553,7 +558,7 @@ function HourlyChart({ hourly, loading }: { hourly: HourlyBucket[] | undefined;
</div> </div>
{/* Hover tooltip */} {/* Hover tooltip */}
<div className="absolute -top-10 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-lg bg-popover px-2.5 py-1.5 text-xs font-medium text-popover-foreground opacity-0 shadow-lg transition-opacity group-hover:opacity-100 pointer-events-none"> <div className="absolute -top-10 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-lg bg-popover px-2.5 py-1.5 text-xs font-medium text-popover-foreground opacity-0 shadow-lg transition-opacity group-hover:opacity-100 pointer-events-none">
{bucket.hour.slice(11, 16)} {bucket.count} msgs {labels[hourly.indexOf(bucket)]} {bucket.count} msgs
</div> </div>
</motion.div> </motion.div>
); );
@@ -987,7 +992,9 @@ function pct(part: number, total: number): number {
} }
function formatTimeAgo(ts: number): string { function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts; // Use Jakarta time as reference for "ago" calculations
const jakartaNow = new Date(new Date().toLocaleString("en-US", { timeZone: "Asia/Jakarta" }));
const diff = jakartaNow.getTime() - ts;
const minutes = Math.floor(diff / 60000); const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "baru saja"; if (minutes < 1) return "baru saja";
if (minutes < 60) return `${minutes}m lalu`; if (minutes < 60) return `${minutes}m lalu`;
@@ -8,14 +8,12 @@ const titles: Record<DashboardTab, string> = {
live: "Voice, Media & Recordings", live: "Voice, Media & Recordings",
messages: "Messages & Moderation", messages: "Messages & Moderation",
analytics: "Analytics & Insights", analytics: "Analytics & Insights",
review: "Moderation Review",
}; };
const subtitles: Record<DashboardTab, string> = { const subtitles: Record<DashboardTab, string> = {
live: "Join voice channels, play media, stream audio, and browse recordings.", live: "Join voice channels, play media, stream audio, and browse recordings.",
messages: "Capture, analyse, and moderate Discord messages.", messages: "Capture, analyse, and moderate Discord messages.",
analytics: "Server moderation statistics and trends.", analytics: "Server moderation statistics and trends.",
review: "Review AI-flagged messages for moderation.",
}; };
interface HeaderProps { interface HeaderProps {
+1 -2
View File
@@ -1,4 +1,4 @@
import { Bot, BarChart3, MessageSquare, ShieldAlert, Radio } from "lucide-react"; import { Bot, BarChart3, MessageSquare, Radio } from "lucide-react";
import type { DashboardTab } from "../../types/ui"; import type { DashboardTab } from "../../types/ui";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
@@ -7,7 +7,6 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> =
{ id: "live", label: "Live", icon: Radio }, { id: "live", label: "Live", icon: Radio },
{ id: "messages", label: "Messages", icon: MessageSquare }, { id: "messages", label: "Messages", icon: MessageSquare },
{ id: "analytics", label: "Analytics", icon: BarChart3 }, { id: "analytics", label: "Analytics", icon: BarChart3 },
{ id: "review", label: "Review", icon: ShieldAlert },
]; ];
interface SidebarProps { interface SidebarProps {
+29 -28
View File
@@ -1,35 +1,36 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { getUIState, updateUIState } from "../api/uiState";
import type { UIState } from "../types/ui"; import type { UIState } from "../types/ui";
export function useUIState() { const STORAGE_KEY = "bete-dashboard-ui-state";
const [uiState, setUIState] = useState<UIState>({ activeTab: "live" });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { function loadState(): UIState {
let cancelled = false; try {
getUIState() const raw = localStorage.getItem(STORAGE_KEY);
.then((state) => { if (raw) return JSON.parse(raw) as UIState;
if (!cancelled) setUIState({ activeTab: "live", ...state }); } catch {
}) // ignore parse errors
.catch((err) => { }
if (!cancelled) setError(err instanceof Error ? err.message : String(err)); return { activeTab: "live" };
}) }
.finally(() => {
if (!cancelled) setLoading(false); function saveState(state: UIState): void {
}); try {
return () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
cancelled = true; } catch {
}; // ignore quota errors
}, []); }
}
export function useUIState() {
const [uiState, setUIState] = useState<UIState>(loadState);
const patchUIState = useCallback(async (patch: Partial<UIState>) => { const patchUIState = useCallback((patch: Partial<UIState>) => {
setUIState((prev) => ({ ...prev, ...patch })); setUIState((prev) => {
const next = await updateUIState(patch); const next = { ...prev, ...patch };
setUIState((prev) => ({ ...prev, ...next })); saveState(next);
return next; return next;
});
}, []); }, []);
return { uiState, setUIState, patchUIState, loading, error }; return { uiState, setUIState, patchUIState, loading: false, error: null };
} }
+1 -1
View File
@@ -1,4 +1,4 @@
export type DashboardTab = "live" | "messages" | "review" | "analytics"; export type DashboardTab = "live" | "messages" | "analytics";
export interface UIState { export interface UIState {
selectedGuild?: string; selectedGuild?: string;