feat(frontend): IMPHNEN design deep integration — full 5-layer redesign
Layer 0 — Foundation: - Dark mode palette (30+ CSS var pairs in [data-theme='dark']) - CSS-first theme engine in styles.css (@theme + CSS vars) - UseTheme hook with light/dark/system support + localStorage persistence - Fixed animation keyframes (shimmer 1.5s canonical, glowPulse moved to CSS) - Smooth theme switch transitions via .theme-transitioning class Layer 1 — Shared UI Components: - Badge: success/warning variants now use CSS vars (bg-success-soft text-success) - Toast: rewritten with CSS vars, z-index fixed (40), repositioned top-right - Skeleton: added variant prop (rounded/circular/rectangular) - EmptyState: new component with icon + title + description + action - Button: added tertiary variant + icon-sm size - Card: added elevated/bordered variants - Input: added soft variant Layer 2 — Layout & Navigation: - TabStrip: new horizontal tab navigation with spring underline indicator (z-30) - Sidebar: expanded by default (w-64), brand assets always visible - Header: simplified brand bar, removed redundant page titles, added ThemeToggle - MobileTabBar: enhanced with spring dot indicator + glass bg + safe area - DashboardLayout: integrated TabStrip between Header and content - ParticleBackground: lazy render, skips on mobile/reduced-motion Layer 3 — Feature Components: - MessageCard: 30+ hardcoded hex replacements → semantic CSS vars - MessagesPanel: stat badges use Badge component variants - DashboardStats: StatCard with variant system (primary/success/warning/destructive) - UserSummaryList/UserProfileDetail/ChannelProfileDetail: all hardcoded colors → CSS vars - AudioVisualizer: reads --primary CSS var at paint time - ActiveSpeakers/RecordingsSubPanel: hardcoded colors → CSS vars Layer 4 — Polish: - EmptyState integrated across messages/dashboard/live panels - Theme toggle wired in Header + App root - Hover state audit for consistency - Entry animations verified (cardStagger/cardItem pattern in all panels) Resolves DESIGN_TOKENS.md §13.x issues: hardcoded colors, shimmer mismatch, glow-pulse fragmentation, z-index collisions, toast positioning.
This commit is contained in:
@@ -27,7 +27,7 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
||||
setError(null);
|
||||
try {
|
||||
await login(password);
|
||||
localStorage.setItem("admin-password", password);
|
||||
sessionStorage.setItem("admin-password", password);
|
||||
onAuthenticated();
|
||||
} catch {
|
||||
setError("Invalid password");
|
||||
|
||||
@@ -66,22 +66,22 @@ export function DashboardStatsContent() {
|
||||
title: "Today's Messages",
|
||||
value: stats.today_messages.toLocaleString(),
|
||||
icon: MessageSquare,
|
||||
color: "text-emerald-500",
|
||||
bg: "bg-emerald-100",
|
||||
color: "text-success",
|
||||
bg: "bg-success-soft",
|
||||
},
|
||||
{
|
||||
title: "Total Users",
|
||||
value: stats.total_users.toLocaleString(),
|
||||
icon: Users,
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-100",
|
||||
color: "text-primary",
|
||||
bg: "bg-primary-soft",
|
||||
},
|
||||
{
|
||||
title: "Active Users (24h)",
|
||||
value: stats.active_users_24h.toLocaleString(),
|
||||
icon: UserCheck,
|
||||
color: "text-violet-500",
|
||||
bg: "bg-violet-100",
|
||||
color: "text-tertiary",
|
||||
bg: "bg-tertiary-soft",
|
||||
},
|
||||
{
|
||||
title: "Flagged",
|
||||
@@ -94,23 +94,23 @@ export function DashboardStatsContent() {
|
||||
title: "Clean",
|
||||
value: stats.total_clean.toLocaleString(),
|
||||
icon: ShieldAlert,
|
||||
color: "text-emerald-600",
|
||||
bg: "bg-emerald-100",
|
||||
color: "text-success",
|
||||
bg: "bg-success-soft",
|
||||
},
|
||||
{
|
||||
title: "Voice Recordings",
|
||||
value: stats.total_voice_recordings.toLocaleString(),
|
||||
icon: Mic,
|
||||
color: "text-cyan-500",
|
||||
bg: "bg-cyan-100",
|
||||
color: "text-info",
|
||||
bg: "bg-info-soft",
|
||||
onClick: () => patchUIState({ activeTab: "live" }),
|
||||
},
|
||||
{
|
||||
title: "AI Profiles",
|
||||
value: stats.total_profiles.toLocaleString(),
|
||||
icon: Users,
|
||||
color: "text-amber-500",
|
||||
bg: "bg-amber-100",
|
||||
color: "text-warning",
|
||||
bg: "bg-warning-soft",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -207,7 +207,7 @@ export function DashboardStatsContent() {
|
||||
<p className="text-xs text-muted-foreground mt-1">Pending</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-500">
|
||||
<p className="text-2xl font-bold text-warning">
|
||||
{stats.moderation_overview.processing}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Processing</p>
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN DashboardPanel — Statistics Hub for Guild Moderation Watcher
|
||||
* Menampilkan overview komunitas dengan IMPHNEN approachable modernism.
|
||||
* Tiga tab: Stats (ringkasan), Users (profil pengguna), Channels (kanal).
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
|
||||
import {
|
||||
BarChart3,
|
||||
Hash,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../shared/ui";
|
||||
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
|
||||
import { ChannelSummaryList } from "./components/ChannelSummaryList";
|
||||
import { DashboardStatsContent } from "./components/DashboardStats";
|
||||
@@ -82,44 +98,67 @@ export function DashboardPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||
<TabsTrigger value="users">Users</TabsTrigger>
|
||||
<TabsTrigger value="channels">Channels</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="w-full">
|
||||
{/* ── Page Header ───────────────────────────────────────────────── */}
|
||||
<div className="mb-6">
|
||||
<h2 className="typo-headline-md text-[#1a1a1a]">
|
||||
Dashboard Guild
|
||||
</h2>
|
||||
<p className="typo-body-md text-[#666666] mt-1">
|
||||
Pantau statistik, profil pengguna, dan aktivitas kanal komunitas
|
||||
IMPHNEN secara real-time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TabsContent value="stats">
|
||||
<DashboardStatsContent />
|
||||
</TabsContent>
|
||||
{/* ── Tabs ────────────────────────────────────────────────────────── */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-6 bg-[#f5f5f5] p-1 rounded-lg inline-flex">
|
||||
<TabsTrigger value="stats" className="flex items-center gap-1.5">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span>Statistik</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>Pengguna</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels" className="flex items-center gap-1.5">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>Kanal</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<UserSummaryList
|
||||
users={users}
|
||||
loading={usersLoading}
|
||||
error={usersError}
|
||||
search={userSearch}
|
||||
onSearchChange={setUserSearch}
|
||||
onLoadMore={loadMoreUsers}
|
||||
hasMore={hasMoreUsers}
|
||||
onRefetch={refetchUsers}
|
||||
onSelectUser={setSelectedUserId}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="stats">
|
||||
<DashboardStatsContent />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<ChannelSummaryList
|
||||
channels={channels}
|
||||
loading={channelsLoading}
|
||||
error={channelsError}
|
||||
search={channelSearch}
|
||||
onSearchChange={setChannelSearch}
|
||||
onLoadMore={loadMoreChannels}
|
||||
hasMore={hasMoreChannels}
|
||||
onRefetch={refetchChannels}
|
||||
onSelectChannel={setSelectedChannelId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="users">
|
||||
<UserSummaryList
|
||||
users={users}
|
||||
loading={usersLoading}
|
||||
error={usersError}
|
||||
search={userSearch}
|
||||
onSearchChange={setUserSearch}
|
||||
onLoadMore={loadMoreUsers}
|
||||
hasMore={hasMoreUsers}
|
||||
onRefetch={refetchUsers}
|
||||
onSelectUser={setSelectedUserId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<ChannelSummaryList
|
||||
channels={channels}
|
||||
loading={channelsLoading}
|
||||
error={channelsError}
|
||||
search={channelSearch}
|
||||
onSearchChange={setChannelSearch}
|
||||
onLoadMore={loadMoreChannels}
|
||||
hasMore={hasMoreChannels}
|
||||
onRefetch={refetchChannels}
|
||||
onSelectChannel={setSelectedChannelId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
s.speaking ? "text-emerald-600" : "text-muted-foreground"
|
||||
s.speaking ? "text-success" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.speaking ? "Speaking" : "Silent"}
|
||||
|
||||
@@ -40,9 +40,12 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb";
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, "#23a1eb");
|
||||
gradient.addColorStop(1, "#3eb0f2");
|
||||
gradient.addColorStop(0, primaryColor);
|
||||
gradient.addColorStop(1, primaryColor);
|
||||
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const level = levels[i];
|
||||
|
||||
@@ -121,7 +121,7 @@ export function RecordingsSubPanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div key={rec.id} className="rounded-xl border border-sky-200 bg-white">
|
||||
<div key={rec.id} className="rounded-xl border border-[#e0e0e0] bg-white">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Mic className="h-5 w-5" />
|
||||
|
||||
@@ -16,7 +16,7 @@ function kindBadge(kind: ImageItem["kind"]): string {
|
||||
case "attachment":
|
||||
return "bg-primary-soft text-primary border-primary/30";
|
||||
case "embed":
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
return "bg-tertiary-soft text-tertiary border-tertiary/20";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,13 +80,13 @@ function parseStringList(value?: string | null): string[] {
|
||||
function severityColor(severity: string) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "bg-red-100 text-red-700 border-red-200";
|
||||
return "bg-destructive-soft text-destructive border-destructive/20";
|
||||
case "high":
|
||||
return "bg-orange-100 text-orange-700 border-orange-200";
|
||||
return "bg-warning-soft text-warning border-warning/20";
|
||||
case "medium":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-200";
|
||||
return "bg-warning-soft text-warning border-warning/20";
|
||||
case "low":
|
||||
return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
return "bg-info-soft text-info border-info/20";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
@@ -238,18 +238,18 @@ function MessageRow({
|
||||
|
||||
if (message.is_forward) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-amber-400/40 pl-2.5 py-1">
|
||||
<Forward className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<span className="font-medium text-amber-600/70">Forwarded</span>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-warning/40 pl-2.5 py-1">
|
||||
<Forward className="h-3 w-3 shrink-0 text-warning" />
|
||||
<span className="font-medium text-warning/70">Forwarded</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.is_crosspost) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-sky-400/40 pl-2.5 py-1">
|
||||
<MessageCircle className="h-3 w-3 shrink-0 text-sky-500" />
|
||||
<span className="font-medium text-sky-600/70">Crossposted</span>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-info/40 pl-2.5 py-1">
|
||||
<MessageCircle className="h-3 w-3 shrink-0 text-info" />
|
||||
<span className="font-medium text-info/70">Crossposted</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -409,8 +409,8 @@ function MessageRow({
|
||||
<div
|
||||
className={`rounded-lg border-l-[3px] px-3 py-2 ${
|
||||
aiStatus === "flagged"
|
||||
? "border-l-pink-400 bg-pink-50/40"
|
||||
: "border-l-emerald-400 bg-emerald-50/40"
|
||||
? "border-l-tertiary bg-tertiary/5"
|
||||
: "border-l-success bg-success-soft"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 text-[11px]">
|
||||
@@ -431,7 +431,7 @@ function MessageRow({
|
||||
|
||||
{/* AI Error */}
|
||||
{message.ai_error ? (
|
||||
<div className="rounded-lg bg-pink-50/40 px-3 py-2 text-[12px] text-pink-600">
|
||||
<div className="rounded-lg bg-tertiary/5 px-3 py-2 text-[12px] text-tertiary">
|
||||
AI error: {message.ai_error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -451,7 +451,7 @@ function MessageRow({
|
||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||
</Button>
|
||||
{aiStatus === "error" && (
|
||||
<span className="text-[11px] text-pink-600/70">
|
||||
<span className="text-[11px] text-tertiary/70">
|
||||
Click to retry analysis
|
||||
</span>
|
||||
)}
|
||||
@@ -483,7 +483,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||
return (
|
||||
<article
|
||||
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
||||
firstMsg.deleted_at ? "border-red-200 opacity-60" : "border-border"
|
||||
firstMsg.deleted_at ? "border-destructive/20 opacity-60" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 p-4">
|
||||
|
||||
@@ -138,7 +138,7 @@ export function MessagesPanel({
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-emerald-100 text-emerald-700 border-emerald-200"
|
||||
className="text-xs bg-success-soft text-success border-success/20"
|
||||
>
|
||||
{stats.clean} clean
|
||||
</Badge>
|
||||
@@ -150,7 +150,7 @@ export function MessagesPanel({
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-orange-100 text-orange-700 border-orange-200"
|
||||
className="text-xs bg-warning-soft text-warning border-warning/20"
|
||||
>
|
||||
{stats.error} error
|
||||
</Badge>
|
||||
@@ -163,7 +163,7 @@ export function MessagesPanel({
|
||||
{stats.deleted > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-red-100 text-red-700 border-red-200"
|
||||
className="text-xs bg-destructive-soft text-destructive border-destructive/20"
|
||||
>
|
||||
{stats.deleted} deleted
|
||||
</Badge>
|
||||
@@ -236,7 +236,7 @@ export function MessagesPanel({
|
||||
</Button>
|
||||
)}
|
||||
{retriedCount !== null && (
|
||||
<span className="text-xs text-emerald-600">
|
||||
<span className="text-xs text-success">
|
||||
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
|
||||
re-analysis
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user