From c2502a0e5f822d356168ff494b67a11b1d08fc98 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 26 Jul 2026 15:34:08 +0700 Subject: [PATCH] feat: add recordings, settings, and voice pages with WebSocket integration - Implemented RecordingsPage to display and manage voice recordings with live updates via WebSocket. - Created SettingsPage for user preferences, including theme toggling and server configuration display. - Developed VoicePage for managing voice connections, including guild and channel selection, and active speaker display. - Introduced GuildSelector component for selecting Discord guilds with error handling and loading states. - Added utility functions for formatting numbers and bytes, and safely parsing JSON. - Established navigation structure for the dashboard with relevant links for new features. --- .../src/app/(dashboard)/analysis/page.tsx | 192 ++++++ .../(dashboard)/dashboard/page.tsx} | 45 +- .../frontend/src/app/(dashboard)/layout.tsx | 45 ++ .../src/app/(dashboard)/media/page.tsx | 214 +++++++ .../(dashboard)/messages/page.tsx} | 145 ++--- .../src/app/(dashboard)/recordings/page.tsx | 134 ++++ .../src/app/(dashboard)/settings/page.tsx | 215 +++++++ .../src/app/(dashboard)/voice/page.tsx | 323 ++++++++++ .../frontend/src/app/dashboard/layout.tsx | 87 --- services/frontend/src/app/dashboard/page.tsx | 209 ------ services/frontend/src/app/page.tsx | 2 +- .../frontend/src/components/layout/header.tsx | 25 + .../src/components/layout/mobile-tab-bar.tsx | 37 +- .../src/components/layout/sidebar.tsx | 35 +- .../mascot/mascot-chatbot.tsx | 0 .../src/components/shared/guild-selector.tsx | 120 ++++ .../frontend/src/features/live/live-panel.tsx | 597 ------------------ services/frontend/src/lib/format.ts | 47 ++ services/frontend/src/lib/navigation.ts | 75 +++ services/frontend/src/lib/tabs.ts | 9 - 20 files changed, 1520 insertions(+), 1036 deletions(-) create mode 100644 services/frontend/src/app/(dashboard)/analysis/page.tsx rename services/frontend/src/{features/dashboard/dashboard-panel.tsx => app/(dashboard)/dashboard/page.tsx} (96%) create mode 100644 services/frontend/src/app/(dashboard)/layout.tsx create mode 100644 services/frontend/src/app/(dashboard)/media/page.tsx rename services/frontend/src/{features/messages/messages-panel.tsx => app/(dashboard)/messages/page.tsx} (91%) create mode 100644 services/frontend/src/app/(dashboard)/recordings/page.tsx create mode 100644 services/frontend/src/app/(dashboard)/settings/page.tsx create mode 100644 services/frontend/src/app/(dashboard)/voice/page.tsx delete mode 100644 services/frontend/src/app/dashboard/layout.tsx delete mode 100644 services/frontend/src/app/dashboard/page.tsx rename services/frontend/src/{features => components}/mascot/mascot-chatbot.tsx (100%) create mode 100644 services/frontend/src/components/shared/guild-selector.tsx delete mode 100644 services/frontend/src/features/live/live-panel.tsx create mode 100644 services/frontend/src/lib/format.ts create mode 100644 services/frontend/src/lib/navigation.ts delete mode 100644 services/frontend/src/lib/tabs.ts diff --git a/services/frontend/src/app/(dashboard)/analysis/page.tsx b/services/frontend/src/app/(dashboard)/analysis/page.tsx new file mode 100644 index 0000000..456438a --- /dev/null +++ b/services/frontend/src/app/(dashboard)/analysis/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { Loader2, RefreshCw, Search, Sparkles } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { messagesApi } from "@/lib/api"; +import { safeParseJsonArray } from "@/lib/format"; +import type { MessageRecord } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +export default function AnalysisPage() { + const [query, setQuery] = useState(""); + const [results, setResults] = useState(null); + const [searching, setSearching] = useState(false); + const [searched, setSearched] = useState(false); + + const handleSearch = useCallback(async () => { + if (!query.trim()) return; + setSearching(true); + setSearched(true); + try { + const result = await messagesApi.search(query, 50); + setResults(result.results); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, [query]); + + const handleReanalyze = useCallback(async (id: string) => { + try { + await messagesApi.reanalyze(id); + } catch { + // ignore + } + }, []); + + return ( +
+ {/* Search */} +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + className="pl-9 h-9" + /> +
+ +
+ + {/* Results */} + {searching ? ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ ) : results !== null ? ( + <> +

+ Found {results.length} result + {results.length !== 1 ? "s" : ""} +

+ + {results.length === 0 ? ( +
+ +

+ No messages found matching your query. +

+
+ ) : ( +
+ {results.map((msg) => ( + + +
+ + + + {msg.username.charAt(0).toUpperCase()} + + + +
+
+ + {msg.username} + + + {new Date(msg.created_at).toLocaleString()} + + {msg.ai_status && ( + + {msg.ai_status} + + )} +
+ +

{msg.content}

+ + {msg.ai_moderation_flags && + msg.ai_moderation_flags !== "[]" && ( +
+ {safeParseJsonArray( + msg.ai_moderation_flags, + ).map((flag) => ( + + {flag} + + ))} +
+ )} + + {msg.ai_analysis && ( +

+ + {msg.ai_analysis} +

+ )} + + {msg.ai_confidence != null && ( +
+ + + {(msg.ai_confidence * 100).toFixed(0)}% + +
+ )} + + +
+
+
+
+ ))} +
+ )} + + ) : !searched ? ( +
+ +

+ Enter a search query to find messages across all channels. +

+

+ Searches message content, AI flags, and analysis text. +

+
+ ) : null} +
+ ); +} diff --git a/services/frontend/src/features/dashboard/dashboard-panel.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx similarity index 96% rename from services/frontend/src/features/dashboard/dashboard-panel.tsx rename to services/frontend/src/app/(dashboard)/dashboard/page.tsx index 54d8361..d66d0c6 100644 --- a/services/frontend/src/features/dashboard/dashboard-panel.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -15,14 +15,17 @@ import { } from "lucide-react"; import Image from "next/image"; import { useCallback, useEffect, useState } from "react"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { GuildSelector } from "@/components/shared/guild-selector"; import { dashboardApi } from "@/lib/api"; +import { formatNumber } from "@/lib/format"; import type { DashboardChannel, DashboardChannelDetail, @@ -31,17 +34,22 @@ import type { DashboardUserDetail, } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { useWebSocket } from "@/lib/ws/context"; type View = "stats" | "users" | "channels" | "user-detail" | "channel-detail"; -export function DashboardPanel({ guildId }: { guildId: string }) { +export default function DashboardPage() { const [view, setView] = useState("stats"); + const [guildId, setGuildId] = useState(""); const [activeUser, setActiveUser] = useState( null, ); const [activeChannel, setActiveChannel] = useState(null); + // WS connection for real-time awareness + useWebSocket(); + const renderView = () => { switch (view) { case "stats": @@ -95,7 +103,8 @@ export function DashboardPanel({ guildId }: { guildId: string }) { return (
- {/* Sub-navigation using shadcn Tabs */} + + - {/* Metric cards */} {loading ? (
{Array.from({ length: 8 }, (_, i) => ( @@ -181,7 +189,11 @@ function StatsView() { value={stats.total_messages} icon={Hash} /> - +
- {/* Top Channels + Moderation Queue */}
@@ -231,12 +242,16 @@ function StatsView() { {stats.top_channels.map((ch, i) => { const maxCount = stats.top_channels[0].message_count; const pct = - maxCount > 0 ? (ch.message_count / maxCount) * 100 : 0; + maxCount > 0 + ? (ch.message_count / maxCount) * 100 + : 0; return (
- #{ch.channel_name ?? ch.channel_id.slice(0, 8)} + # + {ch.channel_name ?? + ch.channel_id.slice(0, 8)} {formatNumber(ch.message_count)} @@ -264,7 +279,9 @@ function StatsView() {
{stats.moderation_overview.pending}
-
Pending
+
+ Pending +
@@ -552,7 +569,7 @@ function ChannelsView({
{ch.culture_summary && (

- "{ch.culture_summary}" + “{ch.culture_summary}”

)} @@ -720,7 +737,7 @@ function ChannelDetailView({

- "{channel.culture_summary}" + “{channel.culture_summary}”

)} @@ -788,9 +805,3 @@ function DetailStat({
); } - -// ── Helpers ───────────────────────────────────── - -function formatNumber(n: number): string { - return n.toLocaleString(); -} diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..45ff48f --- /dev/null +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Suspense } from "react"; + +import { Header } from "@/components/layout/header"; +import { MobileTabBar } from "@/components/layout/mobile-tab-bar"; +import { Sidebar } from "@/components/layout/sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { MascotChatbot } from "@/components/mascot/mascot-chatbot"; +import { WsProvider } from "@/lib/ws/context"; + +function LoadingFallback() { + return ( +
+
+
+

Loading dashboard…

+
+
+ ); +} + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
+ + +
+
+ }>{children} +
+ + +
+
+ +
+ ); +} diff --git a/services/frontend/src/app/(dashboard)/media/page.tsx b/services/frontend/src/app/(dashboard)/media/page.tsx new file mode 100644 index 0000000..d333efd --- /dev/null +++ b/services/frontend/src/app/(dashboard)/media/page.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { + Disc3, + Music, + Play, + SkipForward, + Square, + Volume2, +} from "lucide-react"; +import Image from "next/image"; +import { useCallback, useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Slider } from "@/components/ui/slider"; +import { voiceApi } from "@/lib/api"; +import type { MediaState } from "@/lib/types"; +import { useWebSocket } from "@/lib/ws/context"; + +export default function MediaPage() { + const ws = useWebSocket(); + + const [mediaState, setMediaState] = useState(null); + const [queueUrl, setQueueUrl] = useState(""); + + const fetchMediaStatus = useCallback(async () => { + try { + const state = await voiceApi.getMediaStatus(); + setMediaState(state); + } catch { + // ignore + } + }, []); + + useEffect(() => { + fetchMediaStatus(); + }, [fetchMediaStatus]); + + // WS subscription + useEffect(() => { + const unsubMedia = ws.on("media_state", (state) => { + setMediaState(state as MediaState); + }); + + return () => { + unsubMedia(); + }; + }, [ws]); + + const handleQueueMedia = useCallback(async () => { + if (!queueUrl.trim()) return; + try { + const state = await voiceApi.mediaQueue(queueUrl.trim(), "music"); + setMediaState(state); + setQueueUrl(""); + } catch { + // ignore + } + }, [queueUrl]); + + const handleSkip = useCallback(async () => { + try { + const state = await voiceApi.mediaSkip(); + setMediaState(state); + } catch { + // ignore + } + }, []); + + const handleStop = useCallback(async () => { + try { + const state = await voiceApi.mediaStop(); + setMediaState(state); + } catch { + // ignore + } + }, []); + + const handleVolume = useCallback( + async (value: number | readonly number[]) => { + const vol = Array.isArray(value) ? value[0] : value; + try { + const state = await voiceApi.mediaVolume(vol); + setMediaState(state); + } catch { + // ignore + } + }, + [], + ); + + return ( +
+ + + + + Music Player + + + + {/* Queue URL */} +
+ setQueueUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleQueueMedia()} + className="flex-1 h-9" + /> + +
+ + {/* Now Playing */} + {mediaState?.current && ( +
+

+ + Now Playing +

+
+ {mediaState.current.thumbnailUrl && ( + + )} +
+

+ {mediaState.current.title ?? mediaState.current.source} +

+

+ {mediaState.current.durationMs + ? `${Math.floor( + mediaState.current.durationMs / 60000, + )}:${String( + Math.floor( + (mediaState.current.durationMs % 60000) / 1000, + ), + ).padStart(2, "0")}` + : "Live"} +

+
+
+
+ )} + + {!mediaState?.current && !mediaState?.queue?.length && ( +

+ No media queued. Paste a URL above to start playing. +

+ )} + + {/* Controls */} +
+ + +
+ + +
+
+ + {/* Queue */} + {mediaState && mediaState.queue.length > 0 && ( +
+

+ Queue ({mediaState.queue.length}) +

+
+ {mediaState.queue.map((item, i) => ( +
+ + {i + 1}. + + + {item.title ?? item.source} + +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/services/frontend/src/features/messages/messages-panel.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx similarity index 91% rename from services/frontend/src/features/messages/messages-panel.tsx rename to services/frontend/src/app/(dashboard)/messages/page.tsx index e6bd1cb..b242e8d 100644 --- a/services/frontend/src/features/messages/messages-panel.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -4,15 +4,15 @@ import { AlertCircle, ExternalLink, Flag, - Hash, Loader2, + MessageSquare, RefreshCw, Search, Sparkles, - X, } from "lucide-react"; import Image from "next/image"; import { useCallback, useEffect, useState } from "react"; + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -34,13 +34,16 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { GuildSelector } from "@/components/shared/guild-selector"; import { messagesApi, voiceApi } from "@/lib/api"; +import { formatBytes, safeParseJsonArray } from "@/lib/format"; import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; -export function MessagesPanel({ guildId }: { guildId: string }) { +export default function MessagesPage() { + const [guildId, setGuildId] = useState(""); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -67,8 +70,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) { const ws = useWebSocket(); - // ── Data fetching ── - + // Fetch channels when guild changes useEffect(() => { if (!guildId) return; voiceApi @@ -238,19 +240,26 @@ export function MessagesPanel({ guildId }: { guildId: string }) { if (error) { return ( -
- -

{error}

- +
+ +
+ +

+ {error} +

+ +
); } return (
+ + {/* Search + toolbar */}
@@ -265,7 +274,6 @@ export function MessagesPanel({ guildId }: { guildId: string }) { />
- {/* Channel filter */} {channels.length > 0 && ( + + + + + {guilds.map((g) => ( + + {g.name} + + ))} + + + + {voiceStatus?.connected ? ( + + ) : ( + + )} +
+ + + + {/* Active Speakers */} + {activeSpeakers.length > 0 && ( + + + + + Active Speakers + + + +
+ {activeSpeakers.map((s) => ( +
+ + + + + {s.username} +
+ ))} +
+
+
+ )} + + {/* Microphone */} + + + +
+ + Microphone +
+
+ + {micActive ? "On" : "Off"} + + { + setMicActive(checked); + try { + await voiceApi.sendCommand( + checked + ? "voice:transmit:start" + : "voice:transmit:stop", + ); + } catch { + setMicActive(!checked); + } + }} + disabled={!voiceStatus?.connected} + /> +
+
+
+ + {!voiceStatus?.connected && ( +

+ Connect to a voice channel first. +

+ )} + {micActive && ( +
+ + + + + + Transmitting… + +
+ )} +
+
+
+ ); +} diff --git a/services/frontend/src/app/dashboard/layout.tsx b/services/frontend/src/app/dashboard/layout.tsx deleted file mode 100644 index 8d258ca..0000000 --- a/services/frontend/src/app/dashboard/layout.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useRef } from "react"; - -import { Header } from "@/components/layout/header"; -import { MobileTabBar } from "@/components/layout/mobile-tab-bar"; -import { Sidebar } from "@/components/layout/sidebar"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { MascotChatbot } from "@/features/mascot/mascot-chatbot"; -import { uiStateApi } from "@/lib/api"; -import { WsProvider } from "@/lib/ws/context"; - -function DashboardShell({ children }: { children: React.ReactNode }) { - const searchParams = useSearchParams(); - const router = useRouter(); - const restored = useRef(false); - - const activeTab = (searchParams.get("tab") ?? "messages") as - | "messages" - | "live" - | "dashboard"; - - // Restore persisted tab on mount (only if no explicit tab in URL) - useEffect(() => { - if (restored.current) return; - const tabParam = searchParams.get("tab"); - if (tabParam) { - restored.current = true; - return; - } - uiStateApi - .get() - .then((state) => { - restored.current = true; - const savedTab = state.active_tab; - if (savedTab && savedTab !== activeTab) { - router.replace(`/dashboard?tab=${savedTab}`); - } - }) - .catch(() => { - restored.current = true; - }); - }, [searchParams, activeTab, router]); - - // Persist tab changes - useEffect(() => { - if (!restored.current) return; - uiStateApi.save({ active_tab: activeTab }).catch(() => {}); - }, [activeTab]); - - return ( -
- - -
-
- {children} -
- - -
- ); -} - -export default function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - -
-
- } - > - {children} -
-
- -
- ); -} diff --git a/services/frontend/src/app/dashboard/page.tsx b/services/frontend/src/app/dashboard/page.tsx deleted file mode 100644 index d92764b..0000000 --- a/services/frontend/src/app/dashboard/page.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { AlertCircle, RefreshCw } from "lucide-react"; -import { useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DashboardPanel } from "@/features/dashboard/dashboard-panel"; -import { LivePanel } from "@/features/live/live-panel"; -import { MessagesPanel } from "@/features/messages/messages-panel"; -import { voiceApi } from "@/lib/api"; -import { useAppConfig } from "@/lib/hooks/use-config"; -import type { Guild } from "@/lib/types"; - -export default function DashboardPage() { - const searchParams = useSearchParams(); - const tab = searchParams.get("tab") ?? "messages"; - const urlGuildId = searchParams.get("guildId"); - - 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 | null) => { - if (guildId) setSelectedGuildId(guildId); - }, []); - - const handleRetry = useCallback(() => { - setGuildsLoading(true); - setGuildsError(null); - voiceApi - .getGuilds() - .then(setGuilds) - .catch((err) => - setGuildsError( - err instanceof Error ? err.message : "Failed to load guilds", - ), - ) - .finally(() => setGuildsLoading(false)); - }, []); - - const isReady = !configLoading && !guildsLoading; - - return ( -
- {/* Guild selector bar */} - - - {/* Main panel */} - {isReady ? ( -
- {tab === "live" && } - {tab === "dashboard" && } - {tab === "messages" && } -
- ) : ( -
-
-
-

Loading dashboard…

-
-
- )} -
- ); -} - -// ── Guild Bar ──────────────────────────────────── - -function GuildBar({ - guilds, - loading, - error, - selectedGuildId, - onChange, - onRetry, -}: { - guilds: Guild[]; - loading: boolean; - error: string | null; - selectedGuildId: string; - onChange: (id: string | null) => 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 ( -
- - -
- ); - } - - if (error) { - return ( -
-
- -

- Could not load guilds: {error} -

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

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

-
-
- ); - } - - return ( -
- - Guild - - -
- ); -} diff --git a/services/frontend/src/app/page.tsx b/services/frontend/src/app/page.tsx index 485bdc5..c8618ae 100644 --- a/services/frontend/src/app/page.tsx +++ b/services/frontend/src/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function RootPage() { - redirect("/dashboard?tab=messages"); + redirect("/messages"); } diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx index b7f7ee4..a966482 100644 --- a/services/frontend/src/components/layout/header.tsx +++ b/services/frontend/src/components/layout/header.tsx @@ -1,7 +1,9 @@ "use client"; import { Moon, Sun } from "lucide-react"; +import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { SidebarTrigger } from "@/components/ui/sidebar"; @@ -10,11 +12,32 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { navItems } from "@/lib/navigation"; import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; +function usePageTitle(): string { + const pathname = usePathname(); + + // Exact match first, then prefix match + const item = navItems.find((n) => { + if (n.matchPrefix === "/dashboard") return pathname === "/dashboard"; + return pathname.startsWith(n.matchPrefix); + }); + + if (item) return item.label; + + // Fallback: derive from pathname + const segment = pathname.split("/").filter(Boolean)[0]; + if (segment) { + return segment.charAt(0).toUpperCase() + segment.slice(1); + } + return "Dashboard"; +} + export function Header() { const { status } = useWebSocket(); + const pageTitle = usePageTitle(); const [theme, setTheme] = useState<"light" | "dark">("dark"); useEffect(() => { @@ -50,6 +73,8 @@ export function Header() {
+

{pageTitle}

+
{/* Connection status */} diff --git a/services/frontend/src/components/layout/mobile-tab-bar.tsx b/services/frontend/src/components/layout/mobile-tab-bar.tsx index d7bcf4f..9833750 100644 --- a/services/frontend/src/components/layout/mobile-tab-bar.tsx +++ b/services/frontend/src/components/layout/mobile-tab-bar.tsx @@ -1,40 +1,41 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; -import { type TabId, tabs } from "@/lib/tabs"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { mobileNavItems } from "@/lib/navigation"; import { cn } from "@/lib/utils"; -export function MobileTabBar({ activeTab }: { activeTab: TabId }) { - const router = useRouter(); - const searchParams = useSearchParams(); +export function MobileTabBar() { + const pathname = usePathname(); + + const isActive = (matchPrefix: string) => { + if (matchPrefix === "/dashboard") return pathname === "/dashboard"; + return pathname.startsWith(matchPrefix); + }; return (