diff --git a/services/frontend/src/app/dashboard/page.tsx b/services/frontend/src/app/dashboard/page.tsx index 9e0ea5a..9191662 100644 --- a/services/frontend/src/app/dashboard/page.tsx +++ b/services/frontend/src/app/dashboard/page.tsx @@ -1,6 +1,11 @@ "use client"; +import { Loader2, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; +import { voiceApi } from "@/lib/api"; +import { useAppConfig } from "@/lib/hooks/use-config"; +import type { Guild } from "@/lib/types"; import { DashboardPanel } from "@/features/dashboard/dashboard-panel"; import { LivePanel } from "@/features/live/live-panel"; import { MessagesPanel } from "@/features/messages/messages-panel"; @@ -8,13 +13,173 @@ import { MessagesPanel } from "@/features/messages/messages-panel"; export default function DashboardPage() { const searchParams = useSearchParams(); const tab = searchParams.get("tab") ?? "messages"; + const urlGuildId = searchParams.get("guildId"); - switch (tab) { - case "live": - return ; - case "dashboard": - return ; - default: - return ; + const { config, loading: configLoading } = useAppConfig(); + + const [guilds, setGuilds] = useState([]); + const [guildsLoading, setGuildsLoading] = useState(true); + const [guildsError, setGuildsError] = useState(null); + const [selectedGuildId, setSelectedGuildId] = useState(""); + + // Resolve the active guild ID from: + // 1. URL param (?guildId=xxx) + // 2. Config monitorGuildId + // 3. First available guild from /api/guilds + // 4. Empty (user needs to select) + const resolveGuild = useCallback(() => { + if (urlGuildId) return urlGuildId; + if (config?.monitorGuildId) return config.monitorGuildId; + if (guilds.length > 0) return guilds[0].id; + return ""; + }, [urlGuildId, config?.monitorGuildId, guilds]); + + // Fetch guilds list from backend + useEffect(() => { + let cancelled = false; + setGuildsLoading(true); + setGuildsError(null); + voiceApi + .getGuilds() + .then((g) => { + if (!cancelled) setGuilds(g); + }) + .catch((err) => { + if (!cancelled) setGuildsError(err instanceof Error ? err.message : "Failed to load guilds"); + }) + .finally(() => { + if (!cancelled) setGuildsLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + // Resolve guild ID once config and guilds are loaded + useEffect(() => { + if (configLoading || guildsLoading) return; + const resolved = resolveGuild(); + if (resolved && resolved !== selectedGuildId) { + setSelectedGuildId(resolved); + } + }, [configLoading, guildsLoading, resolveGuild, selectedGuildId]); + + const handleGuildChange = useCallback((guildId: string) => { + setSelectedGuildId(guildId); + }, []); + + const isReady = !configLoading && !guildsLoading; + + return ( +
+ {/* Guild selector bar */} + { + setGuildsLoading(true); + setGuildsError(null); + voiceApi.getGuilds().then(setGuilds).catch( + (err) => setGuildsError(err instanceof Error ? err.message : "Failed to load guilds"), + ).finally(() => setGuildsLoading(false)); + }} + /> + + {/* Main panel */} + {isReady ? ( + <> + {tab === "live" && } + {tab === "dashboard" && ( + + )} + {tab === "messages" && ( + + )} + + ) : ( +
+ +
+ )} +
+ ); +} + +// ── Guild Bar ──────────────────────────────────── + +function GuildBar({ + guilds, + loading, + error, + selectedGuildId, + onChange, + onRetry, +}: { + guilds: Guild[]; + loading: boolean; + error: string | null; + selectedGuildId: string; + onChange: (id: string) => void; + onRetry: () => void; +}) { + // No guild bar if there's only one guild and it's already selected + if (guilds.length <= 1 && !loading && !error) return null; + + if (loading) { + return ( +
+ + Loading guilds… +
+ ); } + + if (error) { + return ( +
+

+ Could not load guilds: {error} +

+ +
+ ); + } + + if (guilds.length === 0) { + return ( +
+

+ No guilds available. Make sure the Discord gateway is connected. +

+
+ ); + } + + return ( +
+ + +
+ ); } diff --git a/services/frontend/src/features/dashboard/dashboard-panel.tsx b/services/frontend/src/features/dashboard/dashboard-panel.tsx index 9560276..f7a8d03 100644 --- a/services/frontend/src/features/dashboard/dashboard-panel.tsx +++ b/services/frontend/src/features/dashboard/dashboard-panel.tsx @@ -23,7 +23,7 @@ import type { type View = "stats" | "users" | "channels" | "user-detail" | "channel-detail"; -export function DashboardPanel() { +export function DashboardPanel({ guildId }: { guildId: string }) { const [view, setView] = useState("stats"); const [activeUser, setActiveUser] = useState( null, @@ -52,6 +52,7 @@ export function DashboardPanel() { case "channels": return ( { try { const detail = await dashboardApi.getChannelDetail(channelId); @@ -76,7 +77,7 @@ export function DashboardPanel() { onBack={() => setView("channels")} /> ) : ( - {}} /> + {}} /> ); } }; @@ -397,8 +398,10 @@ function UsersView({ function ChannelsView({ onSelectChannel, + guildId, }: { onSelectChannel: (channelId: string) => void; + guildId: string; }) { const [channels, setChannels] = useState([]); const [loading, setLoading] = useState(true); @@ -407,14 +410,18 @@ function ChannelsView({ const fetchChannels = useCallback(async (searchQuery?: string) => { setLoading(true); try { - const result = await dashboardApi.listChannels(20, searchQuery); + const result = await dashboardApi.listChannels( + 20, + searchQuery, + guildId || undefined, + ); setChannels(result.data); } catch { // ignore } finally { setLoading(false); } - }, []); + }, [guildId]); useEffect(() => { fetchChannels(); diff --git a/services/frontend/src/features/messages/messages-panel.tsx b/services/frontend/src/features/messages/messages-panel.tsx index ed15c74..24a5dee 100644 --- a/services/frontend/src/features/messages/messages-panel.tsx +++ b/services/frontend/src/features/messages/messages-panel.tsx @@ -2,7 +2,6 @@ import { AlertCircle, - Download, ExternalLink, Flag, Loader2, @@ -12,14 +11,11 @@ import { } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { messagesApi, voiceApi } from "@/lib/api"; -import { useAppConfig } from "@/lib/hooks/use-config"; import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types"; import { useWebSocket } from "@/lib/ws/context"; -export function MessagesPanel() { - const { config } = useAppConfig(); - const guildId = config?.monitorGuildId ?? ""; - +export function MessagesPanel({ guildId }: { guildId: string }) { + // All hooks must be called unconditionally — before the early return. const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -46,9 +42,22 @@ export function MessagesPanel() { const ws = useWebSocket(); + // ── Guild placeholder (after all hooks) ─────────────────── + if (!guildId) { + return ( +
+ +

+ No guild selected. Select a guild above to view messages. +

+
+ ); + } + + // ── Data-fetching side effects (guildId guaranteed non-empty) ── + // Fetch available text channels for filtering useEffect(() => { - if (!guildId) return; voiceApi .getTextChannels(guildId) .then(setChannels) @@ -208,16 +217,16 @@ export function MessagesPanel() { const handleReanalyzeBatch = useCallback(async () => { try { - await messagesApi.reanalyzeBatch(); + await messagesApi.reanalyzeBatch(guildId); } catch { // ignore } - }, []); + }, [guildId]); const displayMessages = searchResults ?? messages; const isEmpty = !loading && displayMessages.length === 0; - // Render + // Render error state if (error) { return (
diff --git a/services/frontend/src/lib/hooks/use-config.ts b/services/frontend/src/lib/hooks/use-config.ts index 741f5cb..e9d5b03 100644 --- a/services/frontend/src/lib/hooks/use-config.ts +++ b/services/frontend/src/lib/hooks/use-config.ts @@ -1,11 +1,6 @@ import { useEffect, useState } from "react"; import { configApi } from "@/lib/api"; - -export interface AppConfig { - monitorGuildId: string | null; - webserverPort?: number; - nodeEnv?: string; -} +import type { AppConfig } from "@/lib/types/guild"; export function useAppConfig() { const [config, setConfig] = useState(null); @@ -15,9 +10,7 @@ export function useAppConfig() { configApi .get() .then((cfg) => { - setConfig({ - monitorGuildId: cfg.monitor_guild_id ?? null, - }); + setConfig(cfg); }) .catch(() => { // silent — config fetch is not critical diff --git a/services/frontend/src/lib/types/guild.ts b/services/frontend/src/lib/types/guild.ts index bae565c..40b31f2 100644 --- a/services/frontend/src/lib/types/guild.ts +++ b/services/frontend/src/lib/types/guild.ts @@ -11,6 +11,19 @@ export interface Channel { parent_id?: string | null; } +/** Shape of the /api/config response (camelCase keys from backend). */ export interface AppConfig { - monitor_guild_id?: string | null; + monitorGuildId: string | null; + webserverPort?: number; + nodeEnv?: string; + backlogSyncHours?: number; + backlogSyncBatchSize?: number; + retentionMessagesDays?: number; + retentionAttachmentsDays?: number; + retentionVoiceDays?: number; + autoDeleteFlaggedEnabled?: boolean; + aiAnalysisEnabled?: boolean; + voiceGuildId?: string | null; + voiceChannelId?: string | null; + logLevel?: string; }