From d78d7a018186ba2c56a8145490b693b614ddde0f Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 18 Aug 2026 09:21:12 +0700 Subject: [PATCH] feat(frontend): skeleton loading states, empty-state glow, light-mode polish - Add shared skeleton building blocks (SkeletonHero, SkeletonMetricRow, SkeletonPanel, SkeletonRows) and wire them into every view's initial loading branch, replacing bare spinners for a cohesive shimmering shell. - Polish EmptyState with a glow-ring icon chip instead of a flat icon. - Harden .light theme: color-scheme, tuned scrollbar + selection for pale canvas. Dark/light theme toggle (next-themes) already persisted in TopBar. --- .../src/app/(dashboard)/analysis/view.tsx | 4 +- .../src/app/(dashboard)/dashboard/view.tsx | 21 +++- .../src/app/(dashboard)/media/view.tsx | 15 ++- .../src/app/(dashboard)/messages/view.tsx | 4 +- .../src/app/(dashboard)/moderation/view.tsx | 13 ++- .../src/app/(dashboard)/recordings/view.tsx | 32 ++++-- .../src/app/(dashboard)/voice/view.tsx | 13 ++- services/frontend/src/app/globals.css | 13 +++ .../frontend/src/components/shared/index.ts | 10 +- .../frontend/src/components/shared/states.tsx | 104 +++++++++++++++++- 10 files changed, 203 insertions(+), 26 deletions(-) diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx index 8cad2c8..1b6dc1c 100644 --- a/services/frontend/src/app/(dashboard)/analysis/view.tsx +++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx @@ -4,7 +4,7 @@ import { Hash, Search, Sparkles, TrendingUp } from "lucide-react"; import { useEffect, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives"; -import { EmptyState, LoadingState, SectionHeader } from "@/components/shared"; +import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared"; import { useChannels, useMessageSearch, useTopReactors } from "@/hooks"; import { aiTone } from "@/lib/ai-status"; import { @@ -70,7 +70,7 @@ export function AnalysisView() { } /> {query.trim().length >= 2 && search.isLoading && ( - + )} {(search.data ?? []).length === 0 ? ( ; - if (!stats && isLoading) return ; + if (!stats && isLoading) + return ( +
+ + + +
+ + +
+
+ ); if (!stats) return ; const s = stats; diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx index 5713174..d413ee6 100644 --- a/services/frontend/src/app/(dashboard)/media/view.tsx +++ b/services/frontend/src/app/(dashboard)/media/view.tsx @@ -12,7 +12,12 @@ import { import { useEffect, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Button, GlassPanel, Input, toast } from "@/components/primitives"; -import { ErrorState, LoadingState, SectionHeader } from "@/components/shared"; +import { + ErrorState, + SectionHeader, + SkeletonHero, + SkeletonPanel, +} from "@/components/shared"; import { useMediaLoop, useMediaQueue, @@ -74,7 +79,13 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { }; if (error && !media) return ; - if (!media && isLoading) return ; + if (!media && isLoading) + return ( +
+ + +
+ ); return (
diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index 637f530..3e406a8 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -22,8 +22,8 @@ import { import { EmptyState, ErrorState, - LoadingState, SectionHeader, + SkeletonRows, } from "@/components/shared"; import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { @@ -185,7 +185,7 @@ export function MessagesView({ {error && !messages ? ( ) : isLoading && !messages ? ( - + ) : list.length === 0 ? ( } diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx index b93796e..59707d2 100644 --- a/services/frontend/src/app/(dashboard)/moderation/view.tsx +++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx @@ -24,9 +24,11 @@ import { } from "@/components/primitives"; import { ErrorState, - LoadingState, MetricTile, SectionHeader, + SkeletonMetricRow, + SkeletonPanel, + SkeletonRows, } from "@/components/shared"; import { useModerationActions, useModerationStats } from "@/hooks"; import { formatNumber, formatRelativeTime } from "@/lib/format"; @@ -92,7 +94,14 @@ export function ModerationView({ }, [failedRate, ambient]); if (error && !stats) return ; - if (!stats && isLoading) return ; + if (!stats && isLoading) + return ( +
+ + + +
+ ); if (!stats) return ; const statusOpts: SelectOption[] = [ diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx index 0e48061..557c875 100644 --- a/services/frontend/src/app/(dashboard)/recordings/view.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -9,14 +9,10 @@ import { Button, GlassCard, GlassPanel, + Skeleton, toast, } from "@/components/primitives"; -import { - EmptyState, - ErrorState, - LoadingState, - SectionHeader, -} from "@/components/shared"; +import { EmptyState, ErrorState, SectionHeader } from "@/components/shared"; import { useDeleteRecording, useRecordings, @@ -55,7 +51,29 @@ export function RecordingsView({ }; if (error && !items) return ; - if (!items && isLoading) return ; + if (!items && isLoading) + return ( + +
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + +
+ +
+ + +
+
+ +
+ ))} +
+
+ ); return ( diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx index ae5767e..5238224 100644 --- a/services/frontend/src/app/(dashboard)/voice/view.tsx +++ b/services/frontend/src/app/(dashboard)/voice/view.tsx @@ -16,8 +16,8 @@ import { Button, GlassPanel, toast } from "@/components/primitives"; import { EmptyState, ErrorState, - LoadingState, SectionHeader, + SkeletonPanel, } from "@/components/shared"; import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { VoiceStage } from "@/components/voice/voice-stage"; @@ -67,7 +67,16 @@ export function VoiceView({ }, [status?.connected, ambient]); if (error && !status) return ; - if (!status && isLoading) return ; + if (!status && isLoading) + return ( +
+ +
+ + +
+
+ ); const connected = status?.connected ?? false; const listenBars = Array.from(listen.levels.values()).slice(0, 32); diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index d2483ef..6960f4f 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -53,6 +53,7 @@ /* ── Light theme overrides ── */ .light { + color-scheme: light; --color-canvas: oklch(0.96 0.012 80); --color-canvas-2: oklch(0.92 0.014 80); --color-surface: oklch(1 0 0 / 0.7); @@ -65,6 +66,18 @@ --color-hairline: oklch(0.22 0.02 70 / 0.12); } +/* Light-theme scrollbar + selection tuned for pale canvas */ +.light ::-webkit-scrollbar-thumb { + background: oklch(0.22 0.02 70 / 0.2); +} +.light ::-webkit-scrollbar-thumb:hover { + background: oklch(0.22 0.02 70 / 0.34); +} +.light ::selection { + background: var(--color-signal-glow); + color: var(--color-ink); +} + @layer base { * { @apply border-transparent; diff --git a/services/frontend/src/components/shared/index.ts b/services/frontend/src/components/shared/index.ts index b57da76..cdac96c 100644 --- a/services/frontend/src/components/shared/index.ts +++ b/services/frontend/src/components/shared/index.ts @@ -1,4 +1,12 @@ export { GuildChannelPicker } from "./guild-picker"; export { MarkdownLite } from "./markdown"; export { MetricTile, SectionHeader } from "./section"; -export { EmptyState, ErrorState, LoadingState } from "./states"; +export { + EmptyState, + ErrorState, + LoadingState, + SkeletonHero, + SkeletonMetricRow, + SkeletonPanel, + SkeletonRows, +} from "./states"; diff --git a/services/frontend/src/components/shared/states.tsx b/services/frontend/src/components/shared/states.tsx index 3aa3b93..520004f 100644 --- a/services/frontend/src/components/shared/states.tsx +++ b/services/frontend/src/components/shared/states.tsx @@ -1,7 +1,97 @@ -import { AlertTriangle, Inbox, Loader2 } from "lucide-react"; -import { Spinner } from "@/components/primitives"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { GlassPanel, Skeleton, Spinner } from "@/components/primitives"; import { cn } from "@/lib/utils"; +/** A panel-shaped loading placeholder. */ +export function SkeletonPanel({ + rows = 3, + className, +}: { + rows?: number; + className?: string; +}) { + return ( + +
+ + +
+
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); +} + +/** A 2×2 (or 4-col) grid of metric-tile placeholders. */ +export function SkeletonMetricRow({ cols = 4 }: { cols?: number }) { + return ( +
+ {Array.from({ length: cols }).map((_, i) => ( +
+ + +
+ ))} +
+ ); +} + +/** A stacked list of row placeholders (message/moderation/queue style). */ +export function SkeletonRows({ + rows = 6, + className, +}: { + rows?: number; + className?: string; +}) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +/** Hero block placeholder (dashboard / media "now playing"). */ +export function SkeletonHero({ className }: { className?: string }) { + return ( + +
+
+
+ + + +
+ +
+ + ); +} + export function EmptyState({ title = "Nothing here yet", description, @@ -16,13 +106,15 @@ export function EmptyState({ return (
-
- {icon ?? } -
+ {icon && ( + + {icon} + + )}
{title}
{description && (
{description}