From eae0d7ce56ee996ecb42711683239e9ca8dd1237 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 26 Jul 2026 15:13:18 +0700 Subject: [PATCH] feat: enhance MessagesPanel with improved UI components and functionality - Refactored MessagesPanel to utilize new UI components such as Avatar, Badge, Button, Card, Dialog, Input, Progress, ScrollArea, Select, Skeleton, and Tabs. - Improved error handling and loading states with enhanced user feedback. - Updated message rendering logic to support new design patterns and animations. - Added support for image previews and improved layout for message details. - Enhanced mobile responsiveness with useIsMobile hook adjustments. - Cleaned up utility functions for better readability and consistency. --- .../frontend/src/app/dashboard/layout.tsx | 32 +- services/frontend/src/app/dashboard/page.tsx | 132 +-- services/frontend/src/app/globals.css | 237 ++++- services/frontend/src/app/layout.tsx | 7 +- .../frontend/src/components/layout/header.tsx | 111 ++- .../src/components/layout/mobile-tab-bar.tsx | 45 +- .../src/components/layout/sidebar.tsx | 147 +++- services/frontend/src/components/ui/chart.tsx | 2 +- .../features/dashboard/dashboard-panel.tsx | 818 ++++++++++-------- .../frontend/src/features/live/live-panel.tsx | 671 +++++++------- .../src/features/mascot/mascot-chatbot.tsx | 198 +++-- .../src/features/messages/messages-panel.tsx | 780 +++++++++-------- services/frontend/src/hooks/use-mobile.ts | 24 +- services/frontend/src/lib/utils.ts | 6 +- 14 files changed, 1907 insertions(+), 1303 deletions(-) diff --git a/services/frontend/src/app/dashboard/layout.tsx b/services/frontend/src/app/dashboard/layout.tsx index fa43a6f..8d258ca 100644 --- a/services/frontend/src/app/dashboard/layout.tsx +++ b/services/frontend/src/app/dashboard/layout.tsx @@ -2,9 +2,11 @@ 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"; @@ -48,12 +50,14 @@ function DashboardShell({ children }: { children: React.ReactNode }) { }, [activeTab]); return ( -
+
-
+
-
{children}
-
+
+ {children} +
+
); @@ -66,15 +70,17 @@ export default function DashboardLayout({ }) { return ( - -
-
- } - > - {children} -
+ + +
+
+ } + > + {children} +
+
); diff --git a/services/frontend/src/app/dashboard/page.tsx b/services/frontend/src/app/dashboard/page.tsx index 76b4667..d92764b 100644 --- a/services/frontend/src/app/dashboard/page.tsx +++ b/services/frontend/src/app/dashboard/page.tsx @@ -1,8 +1,18 @@ "use client"; -import { Loader2, RefreshCw } from "lucide-react"; +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"; @@ -67,14 +77,28 @@ export default function DashboardPage() { } }, [configLoading, guildsLoading, resolveGuild, selectedGuildId]); - const handleGuildChange = useCallback((guildId: string) => { - setSelectedGuildId(guildId); + 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 */} { - setGuildsLoading(true); - setGuildsError(null); - voiceApi - .getGuilds() - .then(setGuilds) - .catch((err) => - setGuildsError( - err instanceof Error ? err.message : "Failed to load guilds", - ), - ) - .finally(() => setGuildsLoading(false)); - }} + onRetry={handleRetry} /> {/* Main panel */} {isReady ? ( - <> +
{tab === "live" && } {tab === "dashboard" && } {tab === "messages" && } - +
) : ( -
- +
+
+
+

Loading dashboard…

+
)}
@@ -127,7 +142,7 @@ function GuildBar({ loading: boolean; error: string | null; selectedGuildId: string; - onChange: (id: string) => void; + onChange: (id: string | null) => void; onRetry: () => void; }) { // No guild bar if there's only one guild and it's already selected @@ -135,61 +150,60 @@ function GuildBar({ 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. -

+
+
+ +

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

+
); } return ( -
- - +
+ + Guild + +
); } diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index e9318c5..4ee4fbd 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -46,6 +46,15 @@ --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + + /* Sky blue accent gradient */ + --accent-gradient: linear-gradient(135deg, oklch(0.65 0.18 240), oklch(0.65 0.15 200), oklch(0.65 0.12 180)); + --accent-gradient-subtle: linear-gradient(135deg, oklch(0.65 0.18 240 / 0.15), oklch(0.65 0.12 180 / 0.05)); + + /* Glass morphism */ + --glass-bg: oklch(1 0 0 / 0.05); + --glass-border: oklch(1 0 0 / 0.1); + --glass-shadow: 0 8px 32px oklch(0 0 0 / 0.3); } :root { @@ -53,68 +62,88 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); + --primary: oklch(0.55 0.18 240); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); + --muted: oklch(0.95 0 0); --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); + --accent: oklch(0.65 0.15 220); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --ring: oklch(0.65 0.18 240); + --chart-1: oklch(0.55 0.18 240); + --chart-2: oklch(0.55 0.15 200); + --chart-3: oklch(0.55 0.12 180); + --chart-4: oklch(0.55 0.2 260); + --chart-5: oklch(0.55 0.15 280); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary: oklch(0.55 0.18 240); --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-accent: oklch(0.95 0 0); + --sidebar-accent-foreground: oklch(0.145 0 0); --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar-ring: oklch(0.65 0.18 240); --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + /* Deep navy-slate base */ + --background: oklch(0.12 0.02 240); + --foreground: oklch(0.92 0.01 240); + + /* Slightly lighter card */ + --card: oklch(0.16 0.025 240); + --card-foreground: oklch(0.92 0.01 240); + + --popover: oklch(0.16 0.025 240); + --popover-foreground: oklch(0.92 0.01 240); + + /* Sky blue primary */ + --primary: oklch(0.65 0.18 240); + --primary-foreground: oklch(0.98 0 0); + + --secondary: oklch(0.22 0.02 240); + --secondary-foreground: oklch(0.92 0.01 240); + + --muted: oklch(0.2 0.015 240); + --muted-foreground: oklch(0.6 0.02 240); + + --accent: oklch(0.7 0.15 220); + --accent-foreground: oklch(0.98 0 0); + + --destructive: oklch(0.6 0.22 25); + + --border: oklch(1 0 0 / 0.08); + --input: oklch(1 0 0 / 0.12); + + --ring: oklch(0.65 0.18 240); + + /* Blue-teal-cyan chart palette */ + --chart-1: oklch(0.65 0.18 240); + --chart-2: oklch(0.6 0.15 200); + --chart-3: oklch(0.6 0.12 180); + --chart-4: oklch(0.7 0.15 220); + --chart-5: oklch(0.55 0.15 260); + + /* Deeper sidebar */ + --sidebar: oklch(0.1 0.015 240); + --sidebar-foreground: oklch(0.92 0.01 240); + --sidebar-primary: oklch(0.65 0.18 240); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.2 0.02 240); + --sidebar-accent-foreground: oklch(0.92 0.01 240); + --sidebar-border: oklch(1 0 0 / 0.06); + --sidebar-ring: oklch(0.65 0.18 240); + + /* Glass overrides for dark */ + --glass-bg: oklch(1 0 0 / 0.05); + --glass-border: oklch(1 0 0 / 0.1); } @layer base { @@ -125,6 +154,122 @@ @apply bg-background text-foreground; } html { - @apply font-sans; + @apply font-sans scroll-smooth; } -} \ No newline at end of file + + /* Custom selection color */ + ::selection { + background: oklch(0.65 0.18 240 / 0.3); + color: inherit; + } + + .dark ::selection { + background: oklch(0.65 0.18 240 / 0.4); + } + + /* Scrollbar styling */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: oklch(1 0 0 / 0.1); + border-radius: 999px; + } + ::-webkit-scrollbar-thumb:hover { + background: oklch(1 0 0 / 0.2); + } +} + +/* ── Utility classes ─────────────────────────── */ + +/* Glass card effect */ +.glass { + background: var(--glass-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); +} + +/* Gradient text */ +.text-gradient { + background: var(--accent-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Gradient border (via pseudo-element trick) */ +.gradient-border { + position: relative; +} +.gradient-border::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: var(--accent-gradient); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +/* Animated background */ +@keyframes gradient-shift { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +.animate-gradient { + background: linear-gradient( + -45deg, + oklch(0.65 0.18 240 / 0.1), + oklch(0.6 0.15 200 / 0.05), + oklch(0.12 0.02 240 / 1), + oklch(0.65 0.12 180 / 0.08) + ); + background-size: 400% 400%; + animation: gradient-shift 15s ease infinite; +} + +/* Counter animation placeholder - will be done in JS */ +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in-up { + animation: fade-in-up 0.3s ease-out forwards; +} + +/* Pulse ring for live indicators */ +@keyframes pulse-ring { + 0% { + transform: scale(0.8); + opacity: 1; + } + 100% { + transform: scale(2.5); + opacity: 0; + } +} + +.live-pulse-ring { + animation: pulse-ring 1.5s ease-out infinite; +} diff --git a/services/frontend/src/app/layout.tsx b/services/frontend/src/app/layout.tsx index 576a678..8c2dbaa 100644 --- a/services/frontend/src/app/layout.tsx +++ b/services/frontend/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import Script from "next/script"; +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; const geistSans = Geist({ @@ -34,7 +36,10 @@ export default function RootLayout({ {`try{const t=localStorage.getItem('theme')||'dark';document.documentElement.classList.add(t)}catch(e){}`} - {children} + + {children} + + ); } diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx index 29cd979..b7f7ee4 100644 --- a/services/frontend/src/components/layout/header.tsx +++ b/services/frontend/src/components/layout/header.tsx @@ -1,7 +1,16 @@ "use client"; -import { AlertCircle, Moon, Sun, Wifi, WifiOff } from "lucide-react"; +import { Moon, Sun } from "lucide-react"; import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; export function Header() { @@ -21,48 +30,82 @@ export function Header() { document.documentElement.classList.add(next); }; + const statusVariant = + status === "connected" + ? "default" + : status === "connecting" + ? "secondary" + : "destructive"; + + const statusLabel = + status === "connected" + ? "Connected" + : status === "connecting" + ? "Connecting" + : status === "error" + ? "Error" + : "Disconnected"; + return ( -
+
+ +
{/* Connection status */} -
- {status === "connected" ? ( - <> - - Connected - - ) : status === "connecting" ? ( - <> - - Connecting - - ) : status === "error" ? ( - <> - - Error - - ) : ( - <> - - Disconnected - - )} -
+ + + + + + {statusLabel} + + + + +

WebSocket: {statusLabel}

+
+
{/* Theme toggle */} - +
+ + +
+
); } diff --git a/services/frontend/src/components/layout/mobile-tab-bar.tsx b/services/frontend/src/components/layout/mobile-tab-bar.tsx index 905ace3..d7bcf4f 100644 --- a/services/frontend/src/components/layout/mobile-tab-bar.tsx +++ b/services/frontend/src/components/layout/mobile-tab-bar.tsx @@ -2,30 +2,41 @@ import { useRouter, useSearchParams } from "next/navigation"; import { type TabId, tabs } from "@/lib/tabs"; +import { cn } from "@/lib/utils"; export function MobileTabBar({ activeTab }: { activeTab: TabId }) { const router = useRouter(); const searchParams = useSearchParams(); return ( -