@@ -437,7 +437,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) { export function MessageCardSkeleton() { return ( - + diff --git a/services/frontend/src/features/messages/index.tsx b/services/frontend/src/features/messages/index.tsx index 501e183..806a205 100644 --- a/services/frontend/src/features/messages/index.tsx +++ b/services/frontend/src/features/messages/index.tsx @@ -103,7 +103,7 @@ export function MessagesPanel({ animate="animate" > - + Messages {guildName && ( @@ -141,7 +141,7 @@ export function MessagesPanel({ {stats.flagged} flagged @@ -224,7 +224,7 @@ export function MessagesPanel({ setRetryingAll(false); } }} - className="rounded-xl bg-pink-100 text-pink-700 hover:bg-pink-200 border-pink-200" + className="rounded-xl bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20" > {f} diff --git a/services/frontend/src/features/tuner/components/CorrectionHistory.tsx b/services/frontend/src/features/tuner/components/CorrectionHistory.tsx index e4304e7..1452fb0 100644 --- a/services/frontend/src/features/tuner/components/CorrectionHistory.tsx +++ b/services/frontend/src/features/tuner/components/CorrectionHistory.tsx @@ -27,7 +27,14 @@ function CorrectionRow({ entry, index, }: { - entry: { id: string; created_at: number; original_flags: string; corrected_flags: string; content_snippet: string; correction_notes: string | null }; + entry: { + id: string; + created_at: number; + original_flags: string; + corrected_flags: string; + content_snippet: string; + correction_notes: string | null; + }; index: number; }) { const originalFlags = parseFlags(entry.original_flags); @@ -45,7 +52,11 @@ function CorrectionRow({ {originalFlags.map((f) => ( - + {f.replace(/_/g, " ")} ))} @@ -53,13 +64,20 @@ function CorrectionRow({ {isCleared ? ( - + Cleared ) : ( {correctedFlags.map((f) => ( - + {f.replace(/_/g, " ")} ))} @@ -77,23 +95,24 @@ function CorrectionRow({ } export function CorrectionHistoryContent() { - const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = useCorrectionHistory(); + const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = + useCorrectionHistory(); if (loading) { return ( - - - - - + + + + + ); } if (error) { return ( - + {error} @@ -112,11 +131,12 @@ export function CorrectionHistoryContent() { if (entries.length === 0) { return ( - + - No corrections submitted yet. Use the Submit tab to record your first correction. + No corrections submitted yet. Use the Submit tab to record your + first correction. @@ -124,7 +144,7 @@ export function CorrectionHistoryContent() { } return ( - + Correction History @@ -159,7 +179,9 @@ export function CorrectionHistoryContent() { disabled={loadingMore} className="gap-1.5 text-xs" > - + {loadingMore ? "Loading..." : "Load More"} diff --git a/services/frontend/src/features/tuner/components/CorrectionStats.tsx b/services/frontend/src/features/tuner/components/CorrectionStats.tsx index dda344a..24f69a9 100644 --- a/services/frontend/src/features/tuner/components/CorrectionStats.tsx +++ b/services/frontend/src/features/tuner/components/CorrectionStats.tsx @@ -10,7 +10,15 @@ import { } from "../../../shared/ui"; import { EmptyStateMascot } from "../../../shared/ui"; -function FlagsBar({ flag, count, max }: { flag: string; count: number; max: number }) { +function FlagsBar({ + flag, + count, + max, +}: { + flag: string; + count: number; + max: number; +}) { const pct = max > 0 ? (count / max) * 100 : 0; return ( @@ -20,7 +28,7 @@ function FlagsBar({ flag, count, max }: { flag: string; count: number; max: numb - - - + + + ); } if (error) { return ( - + {error} @@ -68,11 +76,12 @@ export function CorrectionStatsContent() { if (!stats || stats.total_corrections === 0) { return ( - + - No corrections yet. When admins correct false positives, statistics will appear here. + No corrections yet. When admins correct false positives, statistics + will appear here. @@ -97,7 +106,7 @@ export function CorrectionStatsContent() { animate: { opacity: 1, y: 0, transition: { duration: 0.4 } }, }} > - + Total Corrections @@ -117,7 +126,7 @@ export function CorrectionStatsContent() { animate: { opacity: 1, y: 0, transition: { duration: 0.4 } }, }} > - + Last 7 Days @@ -134,7 +143,7 @@ export function CorrectionStatsContent() { {/* Flags bar chart */} {stats.by_flag.length > 0 && ( - + Most Corrected Flags diff --git a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx index 8a8b446..a25c373 100644 --- a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx +++ b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx @@ -67,7 +67,10 @@ export function SubmitCorrectionContent() { content_snippet: contentSnippet.trim(), }); - addToast("Correction submitted — the AI prompt will learn from this.", "success"); + addToast( + "Correction submitted — the AI prompt will learn from this.", + "success", + ); // Reset form setMessageId(""); @@ -86,14 +89,14 @@ export function SubmitCorrectionContent() { transition={{ duration: 0.4 }} className="max-w-2xl" > - + Submit Correction - Record a false positive — a message that was incorrectly flagged by AI moderation. - This helps the system learn and improve accuracy. + Record a false positive — a message that was incorrectly flagged by + AI moderation. This helps the system learn and improve accuracy. @@ -134,10 +137,20 @@ export function SubmitCorrectionContent() { placeholder="e.g. sexual_deviation" value={flagInput} onChange={(e) => setFlagInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addFlag(); } }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addFlag(); + } + }} className="flex-1" /> - + Add diff --git a/services/frontend/src/features/tuner/hooks/useCorrections.ts b/services/frontend/src/features/tuner/hooks/useCorrections.ts index f3d6fe6..2ce68a0 100644 --- a/services/frontend/src/features/tuner/hooks/useCorrections.ts +++ b/services/frontend/src/features/tuner/hooks/useCorrections.ts @@ -27,7 +27,9 @@ export function useCorrectionStats() { } }, []); - useEffect(() => { fetch().catch(() => undefined); }, [fetch]); + useEffect(() => { + fetch().catch(() => undefined); + }, [fetch]); return { stats, loading, error, refetch: fetch }; } @@ -51,7 +53,9 @@ export function useCorrectionHistory() { cursorRef.current = result.nextCursor; hasMoreRef.current = result.nextCursor !== null; } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load corrections"); + setError( + err instanceof Error ? err.message : "Failed to load corrections", + ); } finally { setLoading(false); } @@ -61,7 +65,10 @@ export function useCorrectionHistory() { if (!cursorRef.current || loadingMore) return; setLoadingMore(true); try { - const result = await listCorrections({ limit: 20, cursor: cursorRef.current }); + const result = await listCorrections({ + limit: 20, + cursor: cursorRef.current, + }); setEntries((prev) => [...prev, ...result.data]); cursorRef.current = result.nextCursor; hasMoreRef.current = result.nextCursor !== null; @@ -72,9 +79,19 @@ export function useCorrectionHistory() { } }, [loadingMore]); - useEffect(() => { fetchInitial().catch(() => undefined); }, [fetchInitial]); + useEffect(() => { + fetchInitial().catch(() => undefined); + }, [fetchInitial]); - return { entries, loading, loadingMore, error, hasMore: hasMoreRef.current, loadMore, refetch: fetchInitial }; + return { + entries, + loading, + loadingMore, + error, + hasMore: hasMoreRef.current, + loadMore, + refetch: fetchInitial, + }; } // ─── Submit ───────────────────────────────────────────────────────────────── @@ -84,28 +101,32 @@ export function useSubmitCorrection() { const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const submit = useCallback(async (data: { - message_id: string; - original_flags: string[]; - corrected_flags: string[]; - correction_notes?: string; - content_snippet: string; - }) => { - setSubmitting(true); - setError(null); - setSuccess(null); - try { - const result = await submitCorrection(data); - setSuccess(result); - return result; - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to submit correction"; - setError(msg); - throw err; - } finally { - setSubmitting(false); - } - }, []); + const submit = useCallback( + async (data: { + message_id: string; + original_flags: string[]; + corrected_flags: string[]; + correction_notes?: string; + content_snippet: string; + }) => { + setSubmitting(true); + setError(null); + setSuccess(null); + try { + const result = await submitCorrection(data); + setSuccess(result); + return result; + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to submit correction"; + setError(msg); + throw err; + } finally { + setSubmitting(false); + } + }, + [], + ); const reset = useCallback(() => { setError(null); diff --git a/services/frontend/src/features/tuner/index.tsx b/services/frontend/src/features/tuner/index.tsx index b9fdfdc..e276db4 100644 --- a/services/frontend/src/features/tuner/index.tsx +++ b/services/frontend/src/features/tuner/index.tsx @@ -1,9 +1,4 @@ -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../shared/ui"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; import { CorrectionStatsContent } from "./components/CorrectionStats"; import { CorrectionHistoryContent } from "./components/CorrectionHistory"; import { SubmitCorrectionContent } from "./components/SubmitCorrection"; diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx index 8aa4b92..1b8a9f5 100644 --- a/services/frontend/src/shared/ui/MobileTabBar.tsx +++ b/services/frontend/src/shared/ui/MobileTabBar.tsx @@ -15,7 +15,7 @@ interface MobileTabBarProps { export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) { return ( - + {tabs.map(({ id, label, Icon }) => ( {label} + {activeTab === id && ( + + )} ))} diff --git a/services/frontend/src/shared/ui/badge.tsx b/services/frontend/src/shared/ui/badge.tsx index 235b44d..b67dccf 100644 --- a/services/frontend/src/shared/ui/badge.tsx +++ b/services/frontend/src/shared/ui/badge.tsx @@ -12,10 +12,10 @@ type BadgeVariant = const variants: Record = { default: "border-transparent bg-primary text-primary-foreground", secondary: "border-transparent bg-muted text-muted-foreground", - destructive: "border-transparent bg-[#FCA5A5] text-red-800", + destructive: "border-transparent bg-destructive/15 text-destructive", outline: "border-border text-foreground", - success: "border-transparent bg-[#BBF7D0] text-green-800", - warning: "border-transparent bg-[#FDE68A] text-amber-800", + success: "border-transparent bg-emerald-100 text-emerald-700", + warning: "border-transparent bg-amber-100 text-amber-700", }; export interface BadgeProps extends React.HTMLAttributes { diff --git a/services/frontend/src/shared/ui/button.tsx b/services/frontend/src/shared/ui/button.tsx index d7196d2..aa8ba09 100644 --- a/services/frontend/src/shared/ui/button.tsx +++ b/services/frontend/src/shared/ui/button.tsx @@ -11,17 +11,19 @@ type ButtonVariant = type ButtonSize = "default" | "sm" | "lg" | "icon"; const variants: Record = { - default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", - secondary: "bg-[#FFB7C5] text-[#1a1a2e] hover:bg-[#FFB7C5]/80", - destructive: "bg-[#FCA5A5] text-[#1a1a2e] hover:bg-[#FCA5A5]/80", - outline: "border border-border bg-white hover:bg-primary-soft", - ghost: "hover:bg-primary-soft", + default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + ghost: "hover:bg-accent hover:text-accent-foreground", }; const sizes: Record = { default: "h-10 px-4 py-2", - sm: "h-9 rounded-xl px-3", - lg: "h-11 rounded-xl px-8", + sm: "h-9 rounded-lg px-3", + lg: "h-11 rounded-lg px-8", icon: "h-10 w-10", }; @@ -43,7 +45,7 @@ export function Button({ return ( - + ); } diff --git a/services/frontend/src/shared/ui/select.tsx b/services/frontend/src/shared/ui/select.tsx index a7bed03..b4d10bd 100644 --- a/services/frontend/src/shared/ui/select.tsx +++ b/services/frontend/src/shared/ui/select.tsx @@ -21,7 +21,7 @@ export function Select({ return ( ) { return ( ); diff --git a/services/frontend/src/shared/ui/tabs.tsx b/services/frontend/src/shared/ui/tabs.tsx index 5fdb05d..4b171ed 100644 --- a/services/frontend/src/shared/ui/tabs.tsx +++ b/services/frontend/src/shared/ui/tabs.tsx @@ -26,7 +26,7 @@ export function TabsTrigger({ return ( = { - info: "border-l-primary bg-white text-foreground", - success: "border-l-green-500 bg-white text-foreground", - error: "border-l-red-500 bg-white text-foreground", - warning: "border-l-amber-500 bg-white text-foreground", + info: "border-l-primary bg-card text-card-foreground", + success: "border-l-emerald-500 bg-card text-card-foreground", + error: "border-l-destructive bg-card text-card-foreground", + warning: "border-l-amber-500 bg-card text-card-foreground", }; -const typeIcons: Record = { - info: "💠", - success: "🌸", - error: "😿", - warning: "⚠️", +const typeIcons: Record = { + info: , + success: , + error: , + warning: , }; function ToastContainer() { @@ -82,15 +89,14 @@ function ToastContainer() { removeToast(toast.id)} > - - {typeIcons[toast.type]} - - {toast.message} + {typeIcons[toast.type]} + {toast.message} + ))} diff --git a/services/frontend/src/styles.css b/services/frontend/src/styles.css index fb167f3..d31291b 100644 --- a/services/frontend/src/styles.css +++ b/services/frontend/src/styles.css @@ -3,40 +3,40 @@ @layer base { :root { - --background: 0 0% 98%; - --foreground: 222 20% 12%; - --card: 0 0% 100%; - --card-foreground: 222 20% 12%; - --primary: 199 80% 50%; - --primary-soft: 199 80% 92%; - --primary-foreground: 0 0% 100%; - --secondary: 210 20% 94%; - --secondary-foreground: 222 20% 12%; - --muted: 210 20% 94%; - --muted-foreground: 215 16% 47%; - --accent: 340 80% 65%; - --accent-foreground: 0 0% 100%; - --destructive: 0 72% 55%; - --destructive-foreground: 0 0% 100%; - --border: 199 30% 85%; - --input: 199 30% 85%; - --ring: 199 80% 50%; + --background: 1 0 0; + --foreground: 0.141 0.005 285.823; + --card: 1 0 0; + --card-foreground: 0.141 0.005 285.823; + --primary: 0.623 0.214 259.815; + --primary-soft: 0.92 0.04 259.815; + --primary-foreground: 0.97 0.014 254.604; + --secondary: 0.967 0.001 286.375; + --secondary-foreground: 0.21 0.006 285.885; + --muted: 0.967 0.001 286.375; + --muted-foreground: 0.552 0.016 285.938; + --accent: 0.967 0.001 286.375; + --accent-foreground: 0.21 0.006 285.885; + --destructive: 0.577 0.245 27.325; + --destructive-foreground: 0.97 0.014 254.604; + --border: 0.92 0.004 286.32; + --input: 0.92 0.004 286.32; + --ring: 0.623 0.214 259.815; --radius: 1rem; - --primary-glow: 199 80% 50% / 0.15; - --accent-glow: 340 80% 65% / 0.15; - --card-shadow: 199 30% 70% / 0.12; + --primary-glow: 0.623 0.214 259.815 / 0.15; + --accent-glow: 0.552 0.016 285.938 / 0.15; + --card-shadow: 0.92 0.004 286.32 / 0.3; } * { - border-color: hsl(var(--border)); + border-color: oklch(var(--border)); } body { - background-color: hsl(var(--background)); - color: hsl(var(--foreground)); + background-color: oklch(var(--background)); + color: oklch(var(--foreground)); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } html, @@ -47,18 +47,27 @@ } @layer utilities { - .card-shadow { - box-shadow: 0 1px 3px hsl(var(--card-shadow)), 0 1px 2px -1px hsl(var(--card-shadow)); + .glass-card { + @apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl; } - .shine { - background: linear-gradient( - 135deg, - hsl(var(--primary-soft)) 0%, - hsl(var(--background)) 50%, - hsl(var(--primary-soft)) 100% - ); - background-size: 200% 200%; + .grid-pattern { + background-image: + linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px), + linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px); + background-size: 40px 40px; + } + + .gradient-text { + @apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400; + } + + .animate-fade-in-up { + animation: fadeInUp 0.5s ease-out; + } + + .animate-fade-in { + animation: fadeIn 0.3s ease-out; } } @@ -80,6 +89,16 @@ } } +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + .animate-bar-pulse { animation: bar-pulse 0.4s ease-in-out infinite; transform-origin: bottom; @@ -88,10 +107,10 @@ .animate-shimmer { background: linear-gradient( 90deg, - hsl(199 30% 90%) 0%, - hsl(199 30% 95%) 40%, - hsl(199 30% 90%) 80%, - hsl(199 30% 85%) 100% + oklch(0.92 0.004 286.32 / 0.5) 0%, + oklch(0.967 0.001 286.375) 40%, + oklch(0.92 0.004 286.32 / 0.5) 80%, + oklch(0.92 0.004 286.32 / 0.7) 100% ); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; diff --git a/services/frontend/src/widgets/DashboardLayout.tsx b/services/frontend/src/widgets/DashboardLayout.tsx index 9457641..6aa670a 100644 --- a/services/frontend/src/widgets/DashboardLayout.tsx +++ b/services/frontend/src/widgets/DashboardLayout.tsx @@ -31,8 +31,12 @@ export function DashboardLayout({ }: DashboardLayoutProps) { return ( - {/* Sakura particle layer */} + {/* Background layers */} + = { - live: "Voice, Media & Recordings", + live: "Voice & Media", messages: "Messages & Moderation", tuner: "Prompt Tuner", }; @@ -51,7 +51,7 @@ function WsIndicator({ status }: { status: WsStatus }) { /** Voice status indicator dot */ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) { const isConnected = voiceStatus.connected; - const dot = isConnected ? "bg-[#7EC8E3]" : "bg-gray-300"; + const dot = isConnected ? "bg-primary" : "bg-gray-300"; const label = isConnected ? voiceStatus.activeChannelName || "connected" : "idle"; @@ -65,9 +65,9 @@ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) { export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { return ( - + - {/* Left: animated tab title + subtitle */} + {/* Left: IMPHNEN brand + tab title */} - + + - GMW + IMPHNEN · {titles[activeTab]} - - {subtitles[activeTab]} - + + {subtitles[activeTab]} + {/* Right: status badges */} @@ -92,7 +97,7 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { {/* WS Badge */} {wsStatus === "connected" ? ( @@ -106,10 +111,8 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { diff --git a/services/frontend/src/widgets/Sidebar.tsx b/services/frontend/src/widgets/Sidebar.tsx index a7a5e37..46a3592 100644 --- a/services/frontend/src/widgets/Sidebar.tsx +++ b/services/frontend/src/widgets/Sidebar.tsx @@ -45,7 +45,7 @@ export function Sidebar({ <> @@ -88,16 +88,9 @@ export function Sidebar({ "group relative flex items-center rounded-xl p-2.5 text-sm font-medium transition-all duration-200 ease-out", collapsed ? "justify-center" : "gap-3", isActive - ? "bg-primary-soft text-primary ring-2 ring-primary/20" - : "text-muted-foreground hover:bg-primary-soft/40 hover:text-primary/80", + ? "bg-primary/10 text-primary ring-1 ring-primary/20" + : "text-muted-foreground hover:bg-primary/5 hover:text-primary/70", )} - onMouseEnter={(e) => { - e.currentTarget.style.transform = - "translateX(2px) scale(1.1)"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = "translateX(0) scale(1)"; - }} > {!collapsed && {item.label}} @@ -114,7 +107,7 @@ export function Sidebar({ mascotChat.setIsOpen(!mascotChat.isOpen)} - className="relative z-50 rounded-2xl p-1 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/40" + className="relative z-50 rounded-xl p-1 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/40" title="Chat dengan mascot" > @@ -125,7 +118,7 @@ export function Sidebar({ isOpen={mascotChat.isOpen} onClose={() => mascotChat.setIsOpen(false)} onSendMessage={mascotChat.handleSendMessage} - mascotName="Discord Watcher" + mascotName="IMPHNEN Mascot" className="fixed bottom-[170px] left-[80px] z-[9999]" /> > diff --git a/services/frontend/src/widgets/mascot/MascotChatbot.tsx b/services/frontend/src/widgets/mascot/MascotChatbot.tsx index bc9be48..e19891d 100644 --- a/services/frontend/src/widgets/mascot/MascotChatbot.tsx +++ b/services/frontend/src/widgets/mascot/MascotChatbot.tsx @@ -106,7 +106,7 @@ export function MascotChatbot({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 20, scale: 0.95 }} className={cn( - "w-96 bg-white rounded-2xl shadow-2xl border border-primary/10 overflow-hidden flex flex-col", + "w-96 bg-card rounded-xl shadow-2xl border border-border overflow-hidden flex flex-col", isMinimized ? "h-16" : "h-[520px]", className, )} @@ -155,7 +155,7 @@ export function MascotChatbot({ {/* Messages */} {!isMinimized && ( <> - + {messages.map((message) => ( @@ -231,7 +231,7 @@ export function MascotChatbot({ {/* Input */} setInput(e.target.value)} placeholder="Tanya mascot..." disabled={loading} - className="flex-1 px-3 py-2 rounded-lg border border-primary/20 focus:outline-none focus:border-primary/50 text-sm disabled:opacity-50" + className="flex-1 px-3 py-2 rounded-lg border border-input bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-sm disabled:opacity-50" /> diff --git a/services/frontend/src/widgets/mascot/MascotImage.tsx b/services/frontend/src/widgets/mascot/MascotImage.tsx index cd15690..af53a3a 100644 --- a/services/frontend/src/widgets/mascot/MascotImage.tsx +++ b/services/frontend/src/widgets/mascot/MascotImage.tsx @@ -70,7 +70,7 @@ export function MascotImage({ > {/* Chat bubble */} - + diff --git a/services/frontend/src/widgets/particles/ParticleBackground.tsx b/services/frontend/src/widgets/particles/ParticleBackground.tsx index ae83836..c6743c0 100644 --- a/services/frontend/src/widgets/particles/ParticleBackground.tsx +++ b/services/frontend/src/widgets/particles/ParticleBackground.tsx @@ -1,160 +1,4 @@ -import { Sparkles } from "@react-three/drei"; -import { Canvas, useFrame } from "@react-three/fiber"; -import { useEffect, useMemo, useRef, useState } from "react"; - -// ─── Particle count by viewport ────────────────────────────────────────────── - -function getPetalCount(width: number) { - if (width < 640) return 10; - if (width < 1024) return 20; - return 35; -} - -function getSparkleCount(width: number) { - if (width < 640) return 15; - return 30; -} - -// ─── Sakura petal mesh ─────────────────────────────────────────────────────── - -interface PetalData { - x: number; - y: number; - z: number; - rotSpeed: number; - driftX: number; - driftZ: number; - hue: number; - sat: number; - light: number; - scale: number; -} - -function createPetals(count: number): PetalData[] { - return Array.from({ length: count }, () => ({ - x: (Math.random() - 0.5) * 20, - y: Math.random() * 20 - 5, - z: (Math.random() - 0.5) * 10 - 2, - rotSpeed: (Math.random() - 0.5) * 2, - driftX: (Math.random() - 0.5) * 0.005, - driftZ: (Math.random() - 0.5) * 0.003, - hue: 340 + Math.random() * 30, - sat: 60 + Math.random() * 30, - light: 65 + Math.random() * 20, - scale: 0.08 + Math.random() * 0.08, - })); -} - -function Petal({ - data, - mouseRef, -}: { - data: PetalData; - mouseRef: React.RefObject<{ x: number; y: number } | null>; -}) { - const meshRef = useRef(null); - - useFrame((_state, delta) => { - const mesh = meshRef.current as unknown as { - position: { x: number; y: number; z: number }; - rotation: { z: number; x: number }; - } | null; - if (!mesh) return; - - // Slow drift downward - mesh.position.y -= delta * 0.3; - mesh.position.x += Math.sin(Date.now() * data.driftX) * 0.002; - mesh.position.z += Math.cos(Date.now() * data.driftZ) * 0.001; - mesh.rotation.z += delta * data.rotSpeed; - mesh.rotation.x += delta * data.rotSpeed * 0.3; - - // Gentle mouse attraction - if (mouseRef.current) { - const dx = mouseRef.current.x * 3 - mesh.position.x; - const dy = mouseRef.current.y * 2 - mesh.position.y; - mesh.position.x += dx * 0.0001; - mesh.position.y += dy * 0.0001; - } - - // Reset when out of view - if (mesh.position.y < -10) { - mesh.position.y = 8; - mesh.position.x = (Math.random() - 0.5) * 20; - mesh.position.z = (Math.random() - 0.5) * 10 - 2; - } - }); - - return ( - - - - - ); -} - -// ─── Scene ─────────────────────────────────────────────────────────────────── - -function ParticleScene() { - const mouseRef = useRef<{ x: number; y: number } | null>(null); - const [viewportWidth, setViewportWidth] = useState( - typeof window !== "undefined" ? window.innerWidth : 1024, - ); - - useEffect(() => { - const onMouse = (e: MouseEvent) => { - mouseRef.current = { - x: (e.clientX / window.innerWidth) * 2 - 1, - y: -(e.clientY / window.innerHeight) * 2 + 1, - }; - }; - window.addEventListener("mousemove", onMouse); - - const onResize = () => setViewportWidth(window.innerWidth); - window.addEventListener("resize", onResize); - - return () => { - window.removeEventListener("mousemove", onMouse); - window.removeEventListener("resize", onResize); - }; - }, []); - - const petalCount = getPetalCount(viewportWidth); - const sparkleCount = getSparkleCount(viewportWidth); - - const petals = useMemo(() => createPetals(petalCount), [petalCount]); - - return ( - <> - - - - {petals.map((data, i) => ( - - ))} - - - > - ); -} - -// ─── Exported component ────────────────────────────────────────────────────── +import { useEffect, useState } from "react"; export function ParticleBackground() { const [reducedMotion, setReducedMotion] = useState(false); @@ -171,18 +15,17 @@ export function ParticleBackground() { return ( - - - + {/* Top-right glow orb */} + + {/* Bottom-left glow orb */} + ); } diff --git a/services/frontend/tailwind.config.js b/services/frontend/tailwind.config.js index 3ce7f27..836e014 100644 --- a/services/frontend/tailwind.config.js +++ b/services/frontend/tailwind.config.js @@ -3,21 +3,24 @@ export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], theme: { extend: { + fontFamily: { + sans: ["Poppins", "ui-sans-serif", "system-ui", "-apple-system", "sans-serif"], + }, colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - "primary-soft": "hsl(var(--primary-soft))", - "primary-glow": "hsl(var(--primary-glow))", - "accent-glow": "hsl(var(--accent-glow))", - primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" }, - secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" }, - muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" }, - accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" }, - destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" }, - card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" }, + border: "oklch(var(--border))", + input: "oklch(var(--input))", + ring: "oklch(var(--ring))", + background: "oklch(var(--background))", + foreground: "oklch(var(--foreground))", + "primary-soft": "oklch(var(--primary-soft))", + "primary-glow": "oklch(var(--primary-glow))", + "accent-glow": "oklch(var(--accent-glow))", + primary: { DEFAULT: "oklch(var(--primary))", foreground: "oklch(var(--primary-foreground))" }, + secondary: { DEFAULT: "oklch(var(--secondary))", foreground: "oklch(var(--secondary-foreground))" }, + muted: { DEFAULT: "oklch(var(--muted))", foreground: "oklch(var(--muted-foreground))" }, + accent: { DEFAULT: "oklch(var(--accent))", foreground: "oklch(var(--accent-foreground))" }, + destructive: { DEFAULT: "oklch(var(--destructive))", foreground: "oklch(var(--destructive-foreground))" }, + card: { DEFAULT: "oklch(var(--card))", foreground: "oklch(var(--card-foreground))" }, }, borderRadius: { lg: "var(--radius)", @@ -27,6 +30,9 @@ export default { animation: { shimmer: "shimmer 2s linear infinite", "bar-pulse": "bar-pulse 0.4s ease-in-out infinite", + "fade-in-up": "fadeInUp 0.5s ease-out", + "fade-in": "fadeIn 0.3s ease-out", + "glow-pulse": "glowPulse 3s ease-in-out infinite", }, keyframes: { shimmer: { @@ -37,6 +43,18 @@ export default { "0%, 100%": { transform: "scaleY(0.8)" }, "50%": { transform: "scaleY(1.2)" }, }, + fadeInUp: { + "0%": { opacity: "0", transform: "translateY(20px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, + fadeIn: { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, + glowPulse: { + "0%, 100%": { opacity: "0.4" }, + "50%": { opacity: "0.8" }, + }, }, }, },
+ diff --git a/services/frontend/src/features/messages/index.tsx b/services/frontend/src/features/messages/index.tsx index 501e183..806a205 100644 --- a/services/frontend/src/features/messages/index.tsx +++ b/services/frontend/src/features/messages/index.tsx @@ -103,7 +103,7 @@ export function MessagesPanel({ animate="animate" > - + Messages {guildName && ( @@ -141,7 +141,7 @@ export function MessagesPanel({ {stats.flagged} flagged @@ -224,7 +224,7 @@ export function MessagesPanel({ setRetryingAll(false); } }} - className="rounded-xl bg-pink-100 text-pink-700 hover:bg-pink-200 border-pink-200" + className="rounded-xl bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20" > {f} diff --git a/services/frontend/src/features/tuner/components/CorrectionHistory.tsx b/services/frontend/src/features/tuner/components/CorrectionHistory.tsx index e4304e7..1452fb0 100644 --- a/services/frontend/src/features/tuner/components/CorrectionHistory.tsx +++ b/services/frontend/src/features/tuner/components/CorrectionHistory.tsx @@ -27,7 +27,14 @@ function CorrectionRow({ entry, index, }: { - entry: { id: string; created_at: number; original_flags: string; corrected_flags: string; content_snippet: string; correction_notes: string | null }; + entry: { + id: string; + created_at: number; + original_flags: string; + corrected_flags: string; + content_snippet: string; + correction_notes: string | null; + }; index: number; }) { const originalFlags = parseFlags(entry.original_flags); @@ -45,7 +52,11 @@ function CorrectionRow({ {originalFlags.map((f) => ( - + {f.replace(/_/g, " ")} ))} @@ -53,13 +64,20 @@ function CorrectionRow({ {isCleared ? ( - + Cleared ) : ( {correctedFlags.map((f) => ( - + {f.replace(/_/g, " ")} ))} @@ -77,23 +95,24 @@ function CorrectionRow({ } export function CorrectionHistoryContent() { - const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = useCorrectionHistory(); + const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = + useCorrectionHistory(); if (loading) { return ( - - - - - + + + + + ); } if (error) { return ( - + {error} @@ -112,11 +131,12 @@ export function CorrectionHistoryContent() { if (entries.length === 0) { return ( - + - No corrections submitted yet. Use the Submit tab to record your first correction. + No corrections submitted yet. Use the Submit tab to record your + first correction. @@ -124,7 +144,7 @@ export function CorrectionHistoryContent() { } return ( - + Correction History @@ -159,7 +179,9 @@ export function CorrectionHistoryContent() { disabled={loadingMore} className="gap-1.5 text-xs" > - + {loadingMore ? "Loading..." : "Load More"} diff --git a/services/frontend/src/features/tuner/components/CorrectionStats.tsx b/services/frontend/src/features/tuner/components/CorrectionStats.tsx index dda344a..24f69a9 100644 --- a/services/frontend/src/features/tuner/components/CorrectionStats.tsx +++ b/services/frontend/src/features/tuner/components/CorrectionStats.tsx @@ -10,7 +10,15 @@ import { } from "../../../shared/ui"; import { EmptyStateMascot } from "../../../shared/ui"; -function FlagsBar({ flag, count, max }: { flag: string; count: number; max: number }) { +function FlagsBar({ + flag, + count, + max, +}: { + flag: string; + count: number; + max: number; +}) { const pct = max > 0 ? (count / max) * 100 : 0; return ( @@ -20,7 +28,7 @@ function FlagsBar({ flag, count, max }: { flag: string; count: number; max: numb - - - + + + ); } if (error) { return ( - + {error} @@ -68,11 +76,12 @@ export function CorrectionStatsContent() { if (!stats || stats.total_corrections === 0) { return ( - + - No corrections yet. When admins correct false positives, statistics will appear here. + No corrections yet. When admins correct false positives, statistics + will appear here. @@ -97,7 +106,7 @@ export function CorrectionStatsContent() { animate: { opacity: 1, y: 0, transition: { duration: 0.4 } }, }} > - + Total Corrections @@ -117,7 +126,7 @@ export function CorrectionStatsContent() { animate: { opacity: 1, y: 0, transition: { duration: 0.4 } }, }} > - + Last 7 Days @@ -134,7 +143,7 @@ export function CorrectionStatsContent() { {/* Flags bar chart */} {stats.by_flag.length > 0 && ( - + Most Corrected Flags diff --git a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx index 8a8b446..a25c373 100644 --- a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx +++ b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx @@ -67,7 +67,10 @@ export function SubmitCorrectionContent() { content_snippet: contentSnippet.trim(), }); - addToast("Correction submitted — the AI prompt will learn from this.", "success"); + addToast( + "Correction submitted — the AI prompt will learn from this.", + "success", + ); // Reset form setMessageId(""); @@ -86,14 +89,14 @@ export function SubmitCorrectionContent() { transition={{ duration: 0.4 }} className="max-w-2xl" > - + Submit Correction - Record a false positive — a message that was incorrectly flagged by AI moderation. - This helps the system learn and improve accuracy. + Record a false positive — a message that was incorrectly flagged by + AI moderation. This helps the system learn and improve accuracy. @@ -134,10 +137,20 @@ export function SubmitCorrectionContent() { placeholder="e.g. sexual_deviation" value={flagInput} onChange={(e) => setFlagInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addFlag(); } }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addFlag(); + } + }} className="flex-1" /> - + Add diff --git a/services/frontend/src/features/tuner/hooks/useCorrections.ts b/services/frontend/src/features/tuner/hooks/useCorrections.ts index f3d6fe6..2ce68a0 100644 --- a/services/frontend/src/features/tuner/hooks/useCorrections.ts +++ b/services/frontend/src/features/tuner/hooks/useCorrections.ts @@ -27,7 +27,9 @@ export function useCorrectionStats() { } }, []); - useEffect(() => { fetch().catch(() => undefined); }, [fetch]); + useEffect(() => { + fetch().catch(() => undefined); + }, [fetch]); return { stats, loading, error, refetch: fetch }; } @@ -51,7 +53,9 @@ export function useCorrectionHistory() { cursorRef.current = result.nextCursor; hasMoreRef.current = result.nextCursor !== null; } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load corrections"); + setError( + err instanceof Error ? err.message : "Failed to load corrections", + ); } finally { setLoading(false); } @@ -61,7 +65,10 @@ export function useCorrectionHistory() { if (!cursorRef.current || loadingMore) return; setLoadingMore(true); try { - const result = await listCorrections({ limit: 20, cursor: cursorRef.current }); + const result = await listCorrections({ + limit: 20, + cursor: cursorRef.current, + }); setEntries((prev) => [...prev, ...result.data]); cursorRef.current = result.nextCursor; hasMoreRef.current = result.nextCursor !== null; @@ -72,9 +79,19 @@ export function useCorrectionHistory() { } }, [loadingMore]); - useEffect(() => { fetchInitial().catch(() => undefined); }, [fetchInitial]); + useEffect(() => { + fetchInitial().catch(() => undefined); + }, [fetchInitial]); - return { entries, loading, loadingMore, error, hasMore: hasMoreRef.current, loadMore, refetch: fetchInitial }; + return { + entries, + loading, + loadingMore, + error, + hasMore: hasMoreRef.current, + loadMore, + refetch: fetchInitial, + }; } // ─── Submit ───────────────────────────────────────────────────────────────── @@ -84,28 +101,32 @@ export function useSubmitCorrection() { const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const submit = useCallback(async (data: { - message_id: string; - original_flags: string[]; - corrected_flags: string[]; - correction_notes?: string; - content_snippet: string; - }) => { - setSubmitting(true); - setError(null); - setSuccess(null); - try { - const result = await submitCorrection(data); - setSuccess(result); - return result; - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to submit correction"; - setError(msg); - throw err; - } finally { - setSubmitting(false); - } - }, []); + const submit = useCallback( + async (data: { + message_id: string; + original_flags: string[]; + corrected_flags: string[]; + correction_notes?: string; + content_snippet: string; + }) => { + setSubmitting(true); + setError(null); + setSuccess(null); + try { + const result = await submitCorrection(data); + setSuccess(result); + return result; + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to submit correction"; + setError(msg); + throw err; + } finally { + setSubmitting(false); + } + }, + [], + ); const reset = useCallback(() => { setError(null); diff --git a/services/frontend/src/features/tuner/index.tsx b/services/frontend/src/features/tuner/index.tsx index b9fdfdc..e276db4 100644 --- a/services/frontend/src/features/tuner/index.tsx +++ b/services/frontend/src/features/tuner/index.tsx @@ -1,9 +1,4 @@ -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../shared/ui"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; import { CorrectionStatsContent } from "./components/CorrectionStats"; import { CorrectionHistoryContent } from "./components/CorrectionHistory"; import { SubmitCorrectionContent } from "./components/SubmitCorrection"; diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx index 8aa4b92..1b8a9f5 100644 --- a/services/frontend/src/shared/ui/MobileTabBar.tsx +++ b/services/frontend/src/shared/ui/MobileTabBar.tsx @@ -15,7 +15,7 @@ interface MobileTabBarProps { export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) { return ( - + {tabs.map(({ id, label, Icon }) => ( {label} + {activeTab === id && ( + + )} ))} diff --git a/services/frontend/src/shared/ui/badge.tsx b/services/frontend/src/shared/ui/badge.tsx index 235b44d..b67dccf 100644 --- a/services/frontend/src/shared/ui/badge.tsx +++ b/services/frontend/src/shared/ui/badge.tsx @@ -12,10 +12,10 @@ type BadgeVariant = const variants: Record = { default: "border-transparent bg-primary text-primary-foreground", secondary: "border-transparent bg-muted text-muted-foreground", - destructive: "border-transparent bg-[#FCA5A5] text-red-800", + destructive: "border-transparent bg-destructive/15 text-destructive", outline: "border-border text-foreground", - success: "border-transparent bg-[#BBF7D0] text-green-800", - warning: "border-transparent bg-[#FDE68A] text-amber-800", + success: "border-transparent bg-emerald-100 text-emerald-700", + warning: "border-transparent bg-amber-100 text-amber-700", }; export interface BadgeProps extends React.HTMLAttributes { diff --git a/services/frontend/src/shared/ui/button.tsx b/services/frontend/src/shared/ui/button.tsx index d7196d2..aa8ba09 100644 --- a/services/frontend/src/shared/ui/button.tsx +++ b/services/frontend/src/shared/ui/button.tsx @@ -11,17 +11,19 @@ type ButtonVariant = type ButtonSize = "default" | "sm" | "lg" | "icon"; const variants: Record = { - default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", - secondary: "bg-[#FFB7C5] text-[#1a1a2e] hover:bg-[#FFB7C5]/80", - destructive: "bg-[#FCA5A5] text-[#1a1a2e] hover:bg-[#FCA5A5]/80", - outline: "border border-border bg-white hover:bg-primary-soft", - ghost: "hover:bg-primary-soft", + default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + ghost: "hover:bg-accent hover:text-accent-foreground", }; const sizes: Record = { default: "h-10 px-4 py-2", - sm: "h-9 rounded-xl px-3", - lg: "h-11 rounded-xl px-8", + sm: "h-9 rounded-lg px-3", + lg: "h-11 rounded-lg px-8", icon: "h-10 w-10", }; @@ -43,7 +45,7 @@ export function Button({ return ( - + ); } diff --git a/services/frontend/src/shared/ui/select.tsx b/services/frontend/src/shared/ui/select.tsx index a7bed03..b4d10bd 100644 --- a/services/frontend/src/shared/ui/select.tsx +++ b/services/frontend/src/shared/ui/select.tsx @@ -21,7 +21,7 @@ export function Select({ return ( ) { return ( ); diff --git a/services/frontend/src/shared/ui/tabs.tsx b/services/frontend/src/shared/ui/tabs.tsx index 5fdb05d..4b171ed 100644 --- a/services/frontend/src/shared/ui/tabs.tsx +++ b/services/frontend/src/shared/ui/tabs.tsx @@ -26,7 +26,7 @@ export function TabsTrigger({ return ( = { - info: "border-l-primary bg-white text-foreground", - success: "border-l-green-500 bg-white text-foreground", - error: "border-l-red-500 bg-white text-foreground", - warning: "border-l-amber-500 bg-white text-foreground", + info: "border-l-primary bg-card text-card-foreground", + success: "border-l-emerald-500 bg-card text-card-foreground", + error: "border-l-destructive bg-card text-card-foreground", + warning: "border-l-amber-500 bg-card text-card-foreground", }; -const typeIcons: Record = { - info: "💠", - success: "🌸", - error: "😿", - warning: "⚠️", +const typeIcons: Record = { + info: , + success: , + error: , + warning: , }; function ToastContainer() { @@ -82,15 +89,14 @@ function ToastContainer() { removeToast(toast.id)} > - - {typeIcons[toast.type]} - - {toast.message} + {typeIcons[toast.type]} + {toast.message} + ))} diff --git a/services/frontend/src/styles.css b/services/frontend/src/styles.css index fb167f3..d31291b 100644 --- a/services/frontend/src/styles.css +++ b/services/frontend/src/styles.css @@ -3,40 +3,40 @@ @layer base { :root { - --background: 0 0% 98%; - --foreground: 222 20% 12%; - --card: 0 0% 100%; - --card-foreground: 222 20% 12%; - --primary: 199 80% 50%; - --primary-soft: 199 80% 92%; - --primary-foreground: 0 0% 100%; - --secondary: 210 20% 94%; - --secondary-foreground: 222 20% 12%; - --muted: 210 20% 94%; - --muted-foreground: 215 16% 47%; - --accent: 340 80% 65%; - --accent-foreground: 0 0% 100%; - --destructive: 0 72% 55%; - --destructive-foreground: 0 0% 100%; - --border: 199 30% 85%; - --input: 199 30% 85%; - --ring: 199 80% 50%; + --background: 1 0 0; + --foreground: 0.141 0.005 285.823; + --card: 1 0 0; + --card-foreground: 0.141 0.005 285.823; + --primary: 0.623 0.214 259.815; + --primary-soft: 0.92 0.04 259.815; + --primary-foreground: 0.97 0.014 254.604; + --secondary: 0.967 0.001 286.375; + --secondary-foreground: 0.21 0.006 285.885; + --muted: 0.967 0.001 286.375; + --muted-foreground: 0.552 0.016 285.938; + --accent: 0.967 0.001 286.375; + --accent-foreground: 0.21 0.006 285.885; + --destructive: 0.577 0.245 27.325; + --destructive-foreground: 0.97 0.014 254.604; + --border: 0.92 0.004 286.32; + --input: 0.92 0.004 286.32; + --ring: 0.623 0.214 259.815; --radius: 1rem; - --primary-glow: 199 80% 50% / 0.15; - --accent-glow: 340 80% 65% / 0.15; - --card-shadow: 199 30% 70% / 0.12; + --primary-glow: 0.623 0.214 259.815 / 0.15; + --accent-glow: 0.552 0.016 285.938 / 0.15; + --card-shadow: 0.92 0.004 286.32 / 0.3; } * { - border-color: hsl(var(--border)); + border-color: oklch(var(--border)); } body { - background-color: hsl(var(--background)); - color: hsl(var(--foreground)); + background-color: oklch(var(--background)); + color: oklch(var(--foreground)); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } html, @@ -47,18 +47,27 @@ } @layer utilities { - .card-shadow { - box-shadow: 0 1px 3px hsl(var(--card-shadow)), 0 1px 2px -1px hsl(var(--card-shadow)); + .glass-card { + @apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl; } - .shine { - background: linear-gradient( - 135deg, - hsl(var(--primary-soft)) 0%, - hsl(var(--background)) 50%, - hsl(var(--primary-soft)) 100% - ); - background-size: 200% 200%; + .grid-pattern { + background-image: + linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px), + linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px); + background-size: 40px 40px; + } + + .gradient-text { + @apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400; + } + + .animate-fade-in-up { + animation: fadeInUp 0.5s ease-out; + } + + .animate-fade-in { + animation: fadeIn 0.3s ease-out; } } @@ -80,6 +89,16 @@ } } +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + .animate-bar-pulse { animation: bar-pulse 0.4s ease-in-out infinite; transform-origin: bottom; @@ -88,10 +107,10 @@ .animate-shimmer { background: linear-gradient( 90deg, - hsl(199 30% 90%) 0%, - hsl(199 30% 95%) 40%, - hsl(199 30% 90%) 80%, - hsl(199 30% 85%) 100% + oklch(0.92 0.004 286.32 / 0.5) 0%, + oklch(0.967 0.001 286.375) 40%, + oklch(0.92 0.004 286.32 / 0.5) 80%, + oklch(0.92 0.004 286.32 / 0.7) 100% ); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; diff --git a/services/frontend/src/widgets/DashboardLayout.tsx b/services/frontend/src/widgets/DashboardLayout.tsx index 9457641..6aa670a 100644 --- a/services/frontend/src/widgets/DashboardLayout.tsx +++ b/services/frontend/src/widgets/DashboardLayout.tsx @@ -31,8 +31,12 @@ export function DashboardLayout({ }: DashboardLayoutProps) { return ( - {/* Sakura particle layer */} + {/* Background layers */} + = { - live: "Voice, Media & Recordings", + live: "Voice & Media", messages: "Messages & Moderation", tuner: "Prompt Tuner", }; @@ -51,7 +51,7 @@ function WsIndicator({ status }: { status: WsStatus }) { /** Voice status indicator dot */ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) { const isConnected = voiceStatus.connected; - const dot = isConnected ? "bg-[#7EC8E3]" : "bg-gray-300"; + const dot = isConnected ? "bg-primary" : "bg-gray-300"; const label = isConnected ? voiceStatus.activeChannelName || "connected" : "idle"; @@ -65,9 +65,9 @@ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) { export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { return ( - + - {/* Left: animated tab title + subtitle */} + {/* Left: IMPHNEN brand + tab title */} - + + - GMW + IMPHNEN · {titles[activeTab]} - - {subtitles[activeTab]} - + + {subtitles[activeTab]} + {/* Right: status badges */} @@ -92,7 +97,7 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { {/* WS Badge */} {wsStatus === "connected" ? ( @@ -106,10 +111,8 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { diff --git a/services/frontend/src/widgets/Sidebar.tsx b/services/frontend/src/widgets/Sidebar.tsx index a7a5e37..46a3592 100644 --- a/services/frontend/src/widgets/Sidebar.tsx +++ b/services/frontend/src/widgets/Sidebar.tsx @@ -45,7 +45,7 @@ export function Sidebar({ <> @@ -88,16 +88,9 @@ export function Sidebar({ "group relative flex items-center rounded-xl p-2.5 text-sm font-medium transition-all duration-200 ease-out", collapsed ? "justify-center" : "gap-3", isActive - ? "bg-primary-soft text-primary ring-2 ring-primary/20" - : "text-muted-foreground hover:bg-primary-soft/40 hover:text-primary/80", + ? "bg-primary/10 text-primary ring-1 ring-primary/20" + : "text-muted-foreground hover:bg-primary/5 hover:text-primary/70", )} - onMouseEnter={(e) => { - e.currentTarget.style.transform = - "translateX(2px) scale(1.1)"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = "translateX(0) scale(1)"; - }} > {!collapsed && {item.label}} @@ -114,7 +107,7 @@ export function Sidebar({ mascotChat.setIsOpen(!mascotChat.isOpen)} - className="relative z-50 rounded-2xl p-1 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/40" + className="relative z-50 rounded-xl p-1 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/40" title="Chat dengan mascot" > @@ -125,7 +118,7 @@ export function Sidebar({ isOpen={mascotChat.isOpen} onClose={() => mascotChat.setIsOpen(false)} onSendMessage={mascotChat.handleSendMessage} - mascotName="Discord Watcher" + mascotName="IMPHNEN Mascot" className="fixed bottom-[170px] left-[80px] z-[9999]" /> > diff --git a/services/frontend/src/widgets/mascot/MascotChatbot.tsx b/services/frontend/src/widgets/mascot/MascotChatbot.tsx index bc9be48..e19891d 100644 --- a/services/frontend/src/widgets/mascot/MascotChatbot.tsx +++ b/services/frontend/src/widgets/mascot/MascotChatbot.tsx @@ -106,7 +106,7 @@ export function MascotChatbot({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 20, scale: 0.95 }} className={cn( - "w-96 bg-white rounded-2xl shadow-2xl border border-primary/10 overflow-hidden flex flex-col", + "w-96 bg-card rounded-xl shadow-2xl border border-border overflow-hidden flex flex-col", isMinimized ? "h-16" : "h-[520px]", className, )} @@ -155,7 +155,7 @@ export function MascotChatbot({ {/* Messages */} {!isMinimized && ( <> - + {messages.map((message) => ( @@ -231,7 +231,7 @@ export function MascotChatbot({ {/* Input */} setInput(e.target.value)} placeholder="Tanya mascot..." disabled={loading} - className="flex-1 px-3 py-2 rounded-lg border border-primary/20 focus:outline-none focus:border-primary/50 text-sm disabled:opacity-50" + className="flex-1 px-3 py-2 rounded-lg border border-input bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-sm disabled:opacity-50" /> diff --git a/services/frontend/src/widgets/mascot/MascotImage.tsx b/services/frontend/src/widgets/mascot/MascotImage.tsx index cd15690..af53a3a 100644 --- a/services/frontend/src/widgets/mascot/MascotImage.tsx +++ b/services/frontend/src/widgets/mascot/MascotImage.tsx @@ -70,7 +70,7 @@ export function MascotImage({ > {/* Chat bubble */} - + diff --git a/services/frontend/src/widgets/particles/ParticleBackground.tsx b/services/frontend/src/widgets/particles/ParticleBackground.tsx index ae83836..c6743c0 100644 --- a/services/frontend/src/widgets/particles/ParticleBackground.tsx +++ b/services/frontend/src/widgets/particles/ParticleBackground.tsx @@ -1,160 +1,4 @@ -import { Sparkles } from "@react-three/drei"; -import { Canvas, useFrame } from "@react-three/fiber"; -import { useEffect, useMemo, useRef, useState } from "react"; - -// ─── Particle count by viewport ────────────────────────────────────────────── - -function getPetalCount(width: number) { - if (width < 640) return 10; - if (width < 1024) return 20; - return 35; -} - -function getSparkleCount(width: number) { - if (width < 640) return 15; - return 30; -} - -// ─── Sakura petal mesh ─────────────────────────────────────────────────────── - -interface PetalData { - x: number; - y: number; - z: number; - rotSpeed: number; - driftX: number; - driftZ: number; - hue: number; - sat: number; - light: number; - scale: number; -} - -function createPetals(count: number): PetalData[] { - return Array.from({ length: count }, () => ({ - x: (Math.random() - 0.5) * 20, - y: Math.random() * 20 - 5, - z: (Math.random() - 0.5) * 10 - 2, - rotSpeed: (Math.random() - 0.5) * 2, - driftX: (Math.random() - 0.5) * 0.005, - driftZ: (Math.random() - 0.5) * 0.003, - hue: 340 + Math.random() * 30, - sat: 60 + Math.random() * 30, - light: 65 + Math.random() * 20, - scale: 0.08 + Math.random() * 0.08, - })); -} - -function Petal({ - data, - mouseRef, -}: { - data: PetalData; - mouseRef: React.RefObject<{ x: number; y: number } | null>; -}) { - const meshRef = useRef(null); - - useFrame((_state, delta) => { - const mesh = meshRef.current as unknown as { - position: { x: number; y: number; z: number }; - rotation: { z: number; x: number }; - } | null; - if (!mesh) return; - - // Slow drift downward - mesh.position.y -= delta * 0.3; - mesh.position.x += Math.sin(Date.now() * data.driftX) * 0.002; - mesh.position.z += Math.cos(Date.now() * data.driftZ) * 0.001; - mesh.rotation.z += delta * data.rotSpeed; - mesh.rotation.x += delta * data.rotSpeed * 0.3; - - // Gentle mouse attraction - if (mouseRef.current) { - const dx = mouseRef.current.x * 3 - mesh.position.x; - const dy = mouseRef.current.y * 2 - mesh.position.y; - mesh.position.x += dx * 0.0001; - mesh.position.y += dy * 0.0001; - } - - // Reset when out of view - if (mesh.position.y < -10) { - mesh.position.y = 8; - mesh.position.x = (Math.random() - 0.5) * 20; - mesh.position.z = (Math.random() - 0.5) * 10 - 2; - } - }); - - return ( - - - - - ); -} - -// ─── Scene ─────────────────────────────────────────────────────────────────── - -function ParticleScene() { - const mouseRef = useRef<{ x: number; y: number } | null>(null); - const [viewportWidth, setViewportWidth] = useState( - typeof window !== "undefined" ? window.innerWidth : 1024, - ); - - useEffect(() => { - const onMouse = (e: MouseEvent) => { - mouseRef.current = { - x: (e.clientX / window.innerWidth) * 2 - 1, - y: -(e.clientY / window.innerHeight) * 2 + 1, - }; - }; - window.addEventListener("mousemove", onMouse); - - const onResize = () => setViewportWidth(window.innerWidth); - window.addEventListener("resize", onResize); - - return () => { - window.removeEventListener("mousemove", onMouse); - window.removeEventListener("resize", onResize); - }; - }, []); - - const petalCount = getPetalCount(viewportWidth); - const sparkleCount = getSparkleCount(viewportWidth); - - const petals = useMemo(() => createPetals(petalCount), [petalCount]); - - return ( - <> - - - - {petals.map((data, i) => ( - - ))} - - - > - ); -} - -// ─── Exported component ────────────────────────────────────────────────────── +import { useEffect, useState } from "react"; export function ParticleBackground() { const [reducedMotion, setReducedMotion] = useState(false); @@ -171,18 +15,17 @@ export function ParticleBackground() { return ( - - - + {/* Top-right glow orb */} + + {/* Bottom-left glow orb */} + ); } diff --git a/services/frontend/tailwind.config.js b/services/frontend/tailwind.config.js index 3ce7f27..836e014 100644 --- a/services/frontend/tailwind.config.js +++ b/services/frontend/tailwind.config.js @@ -3,21 +3,24 @@ export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], theme: { extend: { + fontFamily: { + sans: ["Poppins", "ui-sans-serif", "system-ui", "-apple-system", "sans-serif"], + }, colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - "primary-soft": "hsl(var(--primary-soft))", - "primary-glow": "hsl(var(--primary-glow))", - "accent-glow": "hsl(var(--accent-glow))", - primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" }, - secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" }, - muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" }, - accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" }, - destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" }, - card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" }, + border: "oklch(var(--border))", + input: "oklch(var(--input))", + ring: "oklch(var(--ring))", + background: "oklch(var(--background))", + foreground: "oklch(var(--foreground))", + "primary-soft": "oklch(var(--primary-soft))", + "primary-glow": "oklch(var(--primary-glow))", + "accent-glow": "oklch(var(--accent-glow))", + primary: { DEFAULT: "oklch(var(--primary))", foreground: "oklch(var(--primary-foreground))" }, + secondary: { DEFAULT: "oklch(var(--secondary))", foreground: "oklch(var(--secondary-foreground))" }, + muted: { DEFAULT: "oklch(var(--muted))", foreground: "oklch(var(--muted-foreground))" }, + accent: { DEFAULT: "oklch(var(--accent))", foreground: "oklch(var(--accent-foreground))" }, + destructive: { DEFAULT: "oklch(var(--destructive))", foreground: "oklch(var(--destructive-foreground))" }, + card: { DEFAULT: "oklch(var(--card))", foreground: "oklch(var(--card-foreground))" }, }, borderRadius: { lg: "var(--radius)", @@ -27,6 +30,9 @@ export default { animation: { shimmer: "shimmer 2s linear infinite", "bar-pulse": "bar-pulse 0.4s ease-in-out infinite", + "fade-in-up": "fadeInUp 0.5s ease-out", + "fade-in": "fadeIn 0.3s ease-out", + "glow-pulse": "glowPulse 3s ease-in-out infinite", }, keyframes: { shimmer: { @@ -37,6 +43,18 @@ export default { "0%, 100%": { transform: "scaleY(0.8)" }, "50%": { transform: "scaleY(1.2)" }, }, + fadeInUp: { + "0%": { opacity: "0", transform: "translateY(20px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, + fadeIn: { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, + glowPulse: { + "0%, 100%": { opacity: "0.4" }, + "50%": { opacity: "0.8" }, + }, }, }, },
diff --git a/services/frontend/src/features/messages/index.tsx b/services/frontend/src/features/messages/index.tsx index 501e183..806a205 100644 --- a/services/frontend/src/features/messages/index.tsx +++ b/services/frontend/src/features/messages/index.tsx @@ -103,7 +103,7 @@ export function MessagesPanel({ animate="animate" > - + Messages {guildName && ( @@ -141,7 +141,7 @@ export function MessagesPanel({ {stats.flagged} flagged @@ -224,7 +224,7 @@ export function MessagesPanel({ setRetryingAll(false); } }} - className="rounded-xl bg-pink-100 text-pink-700 hover:bg-pink-200 border-pink-200" + className="rounded-xl bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20" > {f} diff --git a/services/frontend/src/features/tuner/components/CorrectionHistory.tsx b/services/frontend/src/features/tuner/components/CorrectionHistory.tsx index e4304e7..1452fb0 100644 --- a/services/frontend/src/features/tuner/components/CorrectionHistory.tsx +++ b/services/frontend/src/features/tuner/components/CorrectionHistory.tsx @@ -27,7 +27,14 @@ function CorrectionRow({ entry, index, }: { - entry: { id: string; created_at: number; original_flags: string; corrected_flags: string; content_snippet: string; correction_notes: string | null }; + entry: { + id: string; + created_at: number; + original_flags: string; + corrected_flags: string; + content_snippet: string; + correction_notes: string | null; + }; index: number; }) { const originalFlags = parseFlags(entry.original_flags); @@ -45,7 +52,11 @@ function CorrectionRow({ {originalFlags.map((f) => ( - + {f.replace(/_/g, " ")} ))} @@ -53,13 +64,20 @@ function CorrectionRow({ {isCleared ? ( - + Cleared ) : ( {correctedFlags.map((f) => ( - + {f.replace(/_/g, " ")} ))} @@ -77,23 +95,24 @@ function CorrectionRow({ } export function CorrectionHistoryContent() { - const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = useCorrectionHistory(); + const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = + useCorrectionHistory(); if (loading) { return ( - - - - - + + + + + ); } if (error) { return ( - + {error} @@ -112,11 +131,12 @@ export function CorrectionHistoryContent() { if (entries.length === 0) { return ( - + - No corrections submitted yet. Use the Submit tab to record your first correction. + No corrections submitted yet. Use the Submit tab to record your + first correction. @@ -124,7 +144,7 @@ export function CorrectionHistoryContent() { } return ( - + Correction History @@ -159,7 +179,9 @@ export function CorrectionHistoryContent() { disabled={loadingMore} className="gap-1.5 text-xs" > - + {loadingMore ? "Loading..." : "Load More"} diff --git a/services/frontend/src/features/tuner/components/CorrectionStats.tsx b/services/frontend/src/features/tuner/components/CorrectionStats.tsx index dda344a..24f69a9 100644 --- a/services/frontend/src/features/tuner/components/CorrectionStats.tsx +++ b/services/frontend/src/features/tuner/components/CorrectionStats.tsx @@ -10,7 +10,15 @@ import { } from "../../../shared/ui"; import { EmptyStateMascot } from "../../../shared/ui"; -function FlagsBar({ flag, count, max }: { flag: string; count: number; max: number }) { +function FlagsBar({ + flag, + count, + max, +}: { + flag: string; + count: number; + max: number; +}) { const pct = max > 0 ? (count / max) * 100 : 0; return ( @@ -20,7 +28,7 @@ function FlagsBar({ flag, count, max }: { flag: string; count: number; max: numb - - - + + + ); } if (error) { return ( - + {error} @@ -68,11 +76,12 @@ export function CorrectionStatsContent() { if (!stats || stats.total_corrections === 0) { return ( - + - No corrections yet. When admins correct false positives, statistics will appear here. + No corrections yet. When admins correct false positives, statistics + will appear here. @@ -97,7 +106,7 @@ export function CorrectionStatsContent() { animate: { opacity: 1, y: 0, transition: { duration: 0.4 } }, }} > - + Total Corrections @@ -117,7 +126,7 @@ export function CorrectionStatsContent() { animate: { opacity: 1, y: 0, transition: { duration: 0.4 } }, }} > - + Last 7 Days @@ -134,7 +143,7 @@ export function CorrectionStatsContent() { {/* Flags bar chart */} {stats.by_flag.length > 0 && ( - + Most Corrected Flags diff --git a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx index 8a8b446..a25c373 100644 --- a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx +++ b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx @@ -67,7 +67,10 @@ export function SubmitCorrectionContent() { content_snippet: contentSnippet.trim(), }); - addToast("Correction submitted — the AI prompt will learn from this.", "success"); + addToast( + "Correction submitted — the AI prompt will learn from this.", + "success", + ); // Reset form setMessageId(""); @@ -86,14 +89,14 @@ export function SubmitCorrectionContent() { transition={{ duration: 0.4 }} className="max-w-2xl" > - + Submit Correction - Record a false positive — a message that was incorrectly flagged by AI moderation. - This helps the system learn and improve accuracy. + Record a false positive — a message that was incorrectly flagged by + AI moderation. This helps the system learn and improve accuracy. @@ -134,10 +137,20 @@ export function SubmitCorrectionContent() { placeholder="e.g. sexual_deviation" value={flagInput} onChange={(e) => setFlagInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addFlag(); } }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addFlag(); + } + }} className="flex-1" /> - + Add diff --git a/services/frontend/src/features/tuner/hooks/useCorrections.ts b/services/frontend/src/features/tuner/hooks/useCorrections.ts index f3d6fe6..2ce68a0 100644 --- a/services/frontend/src/features/tuner/hooks/useCorrections.ts +++ b/services/frontend/src/features/tuner/hooks/useCorrections.ts @@ -27,7 +27,9 @@ export function useCorrectionStats() { } }, []); - useEffect(() => { fetch().catch(() => undefined); }, [fetch]); + useEffect(() => { + fetch().catch(() => undefined); + }, [fetch]); return { stats, loading, error, refetch: fetch }; } @@ -51,7 +53,9 @@ export function useCorrectionHistory() { cursorRef.current = result.nextCursor; hasMoreRef.current = result.nextCursor !== null; } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load corrections"); + setError( + err instanceof Error ? err.message : "Failed to load corrections", + ); } finally { setLoading(false); } @@ -61,7 +65,10 @@ export function useCorrectionHistory() { if (!cursorRef.current || loadingMore) return; setLoadingMore(true); try { - const result = await listCorrections({ limit: 20, cursor: cursorRef.current }); + const result = await listCorrections({ + limit: 20, + cursor: cursorRef.current, + }); setEntries((prev) => [...prev, ...result.data]); cursorRef.current = result.nextCursor; hasMoreRef.current = result.nextCursor !== null; @@ -72,9 +79,19 @@ export function useCorrectionHistory() { } }, [loadingMore]); - useEffect(() => { fetchInitial().catch(() => undefined); }, [fetchInitial]); + useEffect(() => { + fetchInitial().catch(() => undefined); + }, [fetchInitial]); - return { entries, loading, loadingMore, error, hasMore: hasMoreRef.current, loadMore, refetch: fetchInitial }; + return { + entries, + loading, + loadingMore, + error, + hasMore: hasMoreRef.current, + loadMore, + refetch: fetchInitial, + }; } // ─── Submit ───────────────────────────────────────────────────────────────── @@ -84,28 +101,32 @@ export function useSubmitCorrection() { const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const submit = useCallback(async (data: { - message_id: string; - original_flags: string[]; - corrected_flags: string[]; - correction_notes?: string; - content_snippet: string; - }) => { - setSubmitting(true); - setError(null); - setSuccess(null); - try { - const result = await submitCorrection(data); - setSuccess(result); - return result; - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to submit correction"; - setError(msg); - throw err; - } finally { - setSubmitting(false); - } - }, []); + const submit = useCallback( + async (data: { + message_id: string; + original_flags: string[]; + corrected_flags: string[]; + correction_notes?: string; + content_snippet: string; + }) => { + setSubmitting(true); + setError(null); + setSuccess(null); + try { + const result = await submitCorrection(data); + setSuccess(result); + return result; + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to submit correction"; + setError(msg); + throw err; + } finally { + setSubmitting(false); + } + }, + [], + ); const reset = useCallback(() => { setError(null); diff --git a/services/frontend/src/features/tuner/index.tsx b/services/frontend/src/features/tuner/index.tsx index b9fdfdc..e276db4 100644 --- a/services/frontend/src/features/tuner/index.tsx +++ b/services/frontend/src/features/tuner/index.tsx @@ -1,9 +1,4 @@ -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../shared/ui"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; import { CorrectionStatsContent } from "./components/CorrectionStats"; import { CorrectionHistoryContent } from "./components/CorrectionHistory"; import { SubmitCorrectionContent } from "./components/SubmitCorrection"; diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx index 8aa4b92..1b8a9f5 100644 --- a/services/frontend/src/shared/ui/MobileTabBar.tsx +++ b/services/frontend/src/shared/ui/MobileTabBar.tsx @@ -15,7 +15,7 @@ interface MobileTabBarProps { export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) { return ( - + {tabs.map(({ id, label, Icon }) => ( {label} + {activeTab === id && ( + + )} ))} diff --git a/services/frontend/src/shared/ui/badge.tsx b/services/frontend/src/shared/ui/badge.tsx index 235b44d..b67dccf 100644 --- a/services/frontend/src/shared/ui/badge.tsx +++ b/services/frontend/src/shared/ui/badge.tsx @@ -12,10 +12,10 @@ type BadgeVariant = const variants: Record = { default: "border-transparent bg-primary text-primary-foreground", secondary: "border-transparent bg-muted text-muted-foreground", - destructive: "border-transparent bg-[#FCA5A5] text-red-800", + destructive: "border-transparent bg-destructive/15 text-destructive", outline: "border-border text-foreground", - success: "border-transparent bg-[#BBF7D0] text-green-800", - warning: "border-transparent bg-[#FDE68A] text-amber-800", + success: "border-transparent bg-emerald-100 text-emerald-700", + warning: "border-transparent bg-amber-100 text-amber-700", }; export interface BadgeProps extends React.HTMLAttributes { diff --git a/services/frontend/src/shared/ui/button.tsx b/services/frontend/src/shared/ui/button.tsx index d7196d2..aa8ba09 100644 --- a/services/frontend/src/shared/ui/button.tsx +++ b/services/frontend/src/shared/ui/button.tsx @@ -11,17 +11,19 @@ type ButtonVariant = type ButtonSize = "default" | "sm" | "lg" | "icon"; const variants: Record = { - default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", - secondary: "bg-[#FFB7C5] text-[#1a1a2e] hover:bg-[#FFB7C5]/80", - destructive: "bg-[#FCA5A5] text-[#1a1a2e] hover:bg-[#FCA5A5]/80", - outline: "border border-border bg-white hover:bg-primary-soft", - ghost: "hover:bg-primary-soft", + default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + ghost: "hover:bg-accent hover:text-accent-foreground", }; const sizes: Record = { default: "h-10 px-4 py-2", - sm: "h-9 rounded-xl px-3", - lg: "h-11 rounded-xl px-8", + sm: "h-9 rounded-lg px-3", + lg: "h-11 rounded-lg px-8", icon: "h-10 w-10", }; @@ -43,7 +45,7 @@ export function Button({ return ( - + ); } diff --git a/services/frontend/src/shared/ui/select.tsx b/services/frontend/src/shared/ui/select.tsx index a7bed03..b4d10bd 100644 --- a/services/frontend/src/shared/ui/select.tsx +++ b/services/frontend/src/shared/ui/select.tsx @@ -21,7 +21,7 @@ export function Select({ return ( ) { return ( ); diff --git a/services/frontend/src/shared/ui/tabs.tsx b/services/frontend/src/shared/ui/tabs.tsx index 5fdb05d..4b171ed 100644 --- a/services/frontend/src/shared/ui/tabs.tsx +++ b/services/frontend/src/shared/ui/tabs.tsx @@ -26,7 +26,7 @@ export function TabsTrigger({ return ( = { - info: "border-l-primary bg-white text-foreground", - success: "border-l-green-500 bg-white text-foreground", - error: "border-l-red-500 bg-white text-foreground", - warning: "border-l-amber-500 bg-white text-foreground", + info: "border-l-primary bg-card text-card-foreground", + success: "border-l-emerald-500 bg-card text-card-foreground", + error: "border-l-destructive bg-card text-card-foreground", + warning: "border-l-amber-500 bg-card text-card-foreground", }; -const typeIcons: Record = { - info: "💠", - success: "🌸", - error: "😿", - warning: "⚠️", +const typeIcons: Record = { + info: , + success: , + error: , + warning: , }; function ToastContainer() { @@ -82,15 +89,14 @@ function ToastContainer() { removeToast(toast.id)} > - - {typeIcons[toast.type]} - - {toast.message} + {typeIcons[toast.type]} + {toast.message} + ))} diff --git a/services/frontend/src/styles.css b/services/frontend/src/styles.css index fb167f3..d31291b 100644 --- a/services/frontend/src/styles.css +++ b/services/frontend/src/styles.css @@ -3,40 +3,40 @@ @layer base { :root { - --background: 0 0% 98%; - --foreground: 222 20% 12%; - --card: 0 0% 100%; - --card-foreground: 222 20% 12%; - --primary: 199 80% 50%; - --primary-soft: 199 80% 92%; - --primary-foreground: 0 0% 100%; - --secondary: 210 20% 94%; - --secondary-foreground: 222 20% 12%; - --muted: 210 20% 94%; - --muted-foreground: 215 16% 47%; - --accent: 340 80% 65%; - --accent-foreground: 0 0% 100%; - --destructive: 0 72% 55%; - --destructive-foreground: 0 0% 100%; - --border: 199 30% 85%; - --input: 199 30% 85%; - --ring: 199 80% 50%; + --background: 1 0 0; + --foreground: 0.141 0.005 285.823; + --card: 1 0 0; + --card-foreground: 0.141 0.005 285.823; + --primary: 0.623 0.214 259.815; + --primary-soft: 0.92 0.04 259.815; + --primary-foreground: 0.97 0.014 254.604; + --secondary: 0.967 0.001 286.375; + --secondary-foreground: 0.21 0.006 285.885; + --muted: 0.967 0.001 286.375; + --muted-foreground: 0.552 0.016 285.938; + --accent: 0.967 0.001 286.375; + --accent-foreground: 0.21 0.006 285.885; + --destructive: 0.577 0.245 27.325; + --destructive-foreground: 0.97 0.014 254.604; + --border: 0.92 0.004 286.32; + --input: 0.92 0.004 286.32; + --ring: 0.623 0.214 259.815; --radius: 1rem; - --primary-glow: 199 80% 50% / 0.15; - --accent-glow: 340 80% 65% / 0.15; - --card-shadow: 199 30% 70% / 0.12; + --primary-glow: 0.623 0.214 259.815 / 0.15; + --accent-glow: 0.552 0.016 285.938 / 0.15; + --card-shadow: 0.92 0.004 286.32 / 0.3; } * { - border-color: hsl(var(--border)); + border-color: oklch(var(--border)); } body { - background-color: hsl(var(--background)); - color: hsl(var(--foreground)); + background-color: oklch(var(--background)); + color: oklch(var(--foreground)); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } html, @@ -47,18 +47,27 @@ } @layer utilities { - .card-shadow { - box-shadow: 0 1px 3px hsl(var(--card-shadow)), 0 1px 2px -1px hsl(var(--card-shadow)); + .glass-card { + @apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl; } - .shine { - background: linear-gradient( - 135deg, - hsl(var(--primary-soft)) 0%, - hsl(var(--background)) 50%, - hsl(var(--primary-soft)) 100% - ); - background-size: 200% 200%; + .grid-pattern { + background-image: + linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px), + linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px); + background-size: 40px 40px; + } + + .gradient-text { + @apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400; + } + + .animate-fade-in-up { + animation: fadeInUp 0.5s ease-out; + } + + .animate-fade-in { + animation: fadeIn 0.3s ease-out; } } @@ -80,6 +89,16 @@ } } +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + .animate-bar-pulse { animation: bar-pulse 0.4s ease-in-out infinite; transform-origin: bottom; @@ -88,10 +107,10 @@ .animate-shimmer { background: linear-gradient( 90deg, - hsl(199 30% 90%) 0%, - hsl(199 30% 95%) 40%, - hsl(199 30% 90%) 80%, - hsl(199 30% 85%) 100% + oklch(0.92 0.004 286.32 / 0.5) 0%, + oklch(0.967 0.001 286.375) 40%, + oklch(0.92 0.004 286.32 / 0.5) 80%, + oklch(0.92 0.004 286.32 / 0.7) 100% ); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; diff --git a/services/frontend/src/widgets/DashboardLayout.tsx b/services/frontend/src/widgets/DashboardLayout.tsx index 9457641..6aa670a 100644 --- a/services/frontend/src/widgets/DashboardLayout.tsx +++ b/services/frontend/src/widgets/DashboardLayout.tsx @@ -31,8 +31,12 @@ export function DashboardLayout({ }: DashboardLayoutProps) { return ( - {/* Sakura particle layer */} + {/* Background layers */} + = { - live: "Voice, Media & Recordings", + live: "Voice & Media", messages: "Messages & Moderation", tuner: "Prompt Tuner", }; @@ -51,7 +51,7 @@ function WsIndicator({ status }: { status: WsStatus }) { /** Voice status indicator dot */ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) { const isConnected = voiceStatus.connected; - const dot = isConnected ? "bg-[#7EC8E3]" : "bg-gray-300"; + const dot = isConnected ? "bg-primary" : "bg-gray-300"; const label = isConnected ? voiceStatus.activeChannelName || "connected" : "idle"; @@ -65,9 +65,9 @@ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) { export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { return ( - + - {/* Left: animated tab title + subtitle */} + {/* Left: IMPHNEN brand + tab title */} - + + - GMW + IMPHNEN · {titles[activeTab]} - - {subtitles[activeTab]} - + + {subtitles[activeTab]} + {/* Right: status badges */} @@ -92,7 +97,7 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { {/* WS Badge */} {wsStatus === "connected" ? ( @@ -106,10 +111,8 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) { diff --git a/services/frontend/src/widgets/Sidebar.tsx b/services/frontend/src/widgets/Sidebar.tsx index a7a5e37..46a3592 100644 --- a/services/frontend/src/widgets/Sidebar.tsx +++ b/services/frontend/src/widgets/Sidebar.tsx @@ -45,7 +45,7 @@ export function Sidebar({ <> @@ -88,16 +88,9 @@ export function Sidebar({ "group relative flex items-center rounded-xl p-2.5 text-sm font-medium transition-all duration-200 ease-out", collapsed ? "justify-center" : "gap-3", isActive - ? "bg-primary-soft text-primary ring-2 ring-primary/20" - : "text-muted-foreground hover:bg-primary-soft/40 hover:text-primary/80", + ? "bg-primary/10 text-primary ring-1 ring-primary/20" + : "text-muted-foreground hover:bg-primary/5 hover:text-primary/70", )} - onMouseEnter={(e) => { - e.currentTarget.style.transform = - "translateX(2px) scale(1.1)"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = "translateX(0) scale(1)"; - }} > {!collapsed && {item.label}} @@ -114,7 +107,7 @@ export function Sidebar({ mascotChat.setIsOpen(!mascotChat.isOpen)} - className="relative z-50 rounded-2xl p-1 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/40" + className="relative z-50 rounded-xl p-1 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/40" title="Chat dengan mascot" > @@ -125,7 +118,7 @@ export function Sidebar({ isOpen={mascotChat.isOpen} onClose={() => mascotChat.setIsOpen(false)} onSendMessage={mascotChat.handleSendMessage} - mascotName="Discord Watcher" + mascotName="IMPHNEN Mascot" className="fixed bottom-[170px] left-[80px] z-[9999]" /> > diff --git a/services/frontend/src/widgets/mascot/MascotChatbot.tsx b/services/frontend/src/widgets/mascot/MascotChatbot.tsx index bc9be48..e19891d 100644 --- a/services/frontend/src/widgets/mascot/MascotChatbot.tsx +++ b/services/frontend/src/widgets/mascot/MascotChatbot.tsx @@ -106,7 +106,7 @@ export function MascotChatbot({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 20, scale: 0.95 }} className={cn( - "w-96 bg-white rounded-2xl shadow-2xl border border-primary/10 overflow-hidden flex flex-col", + "w-96 bg-card rounded-xl shadow-2xl border border-border overflow-hidden flex flex-col", isMinimized ? "h-16" : "h-[520px]", className, )} @@ -155,7 +155,7 @@ export function MascotChatbot({ {/* Messages */} {!isMinimized && ( <> - + {messages.map((message) => ( @@ -231,7 +231,7 @@ export function MascotChatbot({ {/* Input */} setInput(e.target.value)} placeholder="Tanya mascot..." disabled={loading} - className="flex-1 px-3 py-2 rounded-lg border border-primary/20 focus:outline-none focus:border-primary/50 text-sm disabled:opacity-50" + className="flex-1 px-3 py-2 rounded-lg border border-input bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-sm disabled:opacity-50" /> diff --git a/services/frontend/src/widgets/mascot/MascotImage.tsx b/services/frontend/src/widgets/mascot/MascotImage.tsx index cd15690..af53a3a 100644 --- a/services/frontend/src/widgets/mascot/MascotImage.tsx +++ b/services/frontend/src/widgets/mascot/MascotImage.tsx @@ -70,7 +70,7 @@ export function MascotImage({ > {/* Chat bubble */} - + diff --git a/services/frontend/src/widgets/particles/ParticleBackground.tsx b/services/frontend/src/widgets/particles/ParticleBackground.tsx index ae83836..c6743c0 100644 --- a/services/frontend/src/widgets/particles/ParticleBackground.tsx +++ b/services/frontend/src/widgets/particles/ParticleBackground.tsx @@ -1,160 +1,4 @@ -import { Sparkles } from "@react-three/drei"; -import { Canvas, useFrame } from "@react-three/fiber"; -import { useEffect, useMemo, useRef, useState } from "react"; - -// ─── Particle count by viewport ────────────────────────────────────────────── - -function getPetalCount(width: number) { - if (width < 640) return 10; - if (width < 1024) return 20; - return 35; -} - -function getSparkleCount(width: number) { - if (width < 640) return 15; - return 30; -} - -// ─── Sakura petal mesh ─────────────────────────────────────────────────────── - -interface PetalData { - x: number; - y: number; - z: number; - rotSpeed: number; - driftX: number; - driftZ: number; - hue: number; - sat: number; - light: number; - scale: number; -} - -function createPetals(count: number): PetalData[] { - return Array.from({ length: count }, () => ({ - x: (Math.random() - 0.5) * 20, - y: Math.random() * 20 - 5, - z: (Math.random() - 0.5) * 10 - 2, - rotSpeed: (Math.random() - 0.5) * 2, - driftX: (Math.random() - 0.5) * 0.005, - driftZ: (Math.random() - 0.5) * 0.003, - hue: 340 + Math.random() * 30, - sat: 60 + Math.random() * 30, - light: 65 + Math.random() * 20, - scale: 0.08 + Math.random() * 0.08, - })); -} - -function Petal({ - data, - mouseRef, -}: { - data: PetalData; - mouseRef: React.RefObject<{ x: number; y: number } | null>; -}) { - const meshRef = useRef(null); - - useFrame((_state, delta) => { - const mesh = meshRef.current as unknown as { - position: { x: number; y: number; z: number }; - rotation: { z: number; x: number }; - } | null; - if (!mesh) return; - - // Slow drift downward - mesh.position.y -= delta * 0.3; - mesh.position.x += Math.sin(Date.now() * data.driftX) * 0.002; - mesh.position.z += Math.cos(Date.now() * data.driftZ) * 0.001; - mesh.rotation.z += delta * data.rotSpeed; - mesh.rotation.x += delta * data.rotSpeed * 0.3; - - // Gentle mouse attraction - if (mouseRef.current) { - const dx = mouseRef.current.x * 3 - mesh.position.x; - const dy = mouseRef.current.y * 2 - mesh.position.y; - mesh.position.x += dx * 0.0001; - mesh.position.y += dy * 0.0001; - } - - // Reset when out of view - if (mesh.position.y < -10) { - mesh.position.y = 8; - mesh.position.x = (Math.random() - 0.5) * 20; - mesh.position.z = (Math.random() - 0.5) * 10 - 2; - } - }); - - return ( - - - - - ); -} - -// ─── Scene ─────────────────────────────────────────────────────────────────── - -function ParticleScene() { - const mouseRef = useRef<{ x: number; y: number } | null>(null); - const [viewportWidth, setViewportWidth] = useState( - typeof window !== "undefined" ? window.innerWidth : 1024, - ); - - useEffect(() => { - const onMouse = (e: MouseEvent) => { - mouseRef.current = { - x: (e.clientX / window.innerWidth) * 2 - 1, - y: -(e.clientY / window.innerHeight) * 2 + 1, - }; - }; - window.addEventListener("mousemove", onMouse); - - const onResize = () => setViewportWidth(window.innerWidth); - window.addEventListener("resize", onResize); - - return () => { - window.removeEventListener("mousemove", onMouse); - window.removeEventListener("resize", onResize); - }; - }, []); - - const petalCount = getPetalCount(viewportWidth); - const sparkleCount = getSparkleCount(viewportWidth); - - const petals = useMemo(() => createPetals(petalCount), [petalCount]); - - return ( - <> - - - - {petals.map((data, i) => ( - - ))} - - - > - ); -} - -// ─── Exported component ────────────────────────────────────────────────────── +import { useEffect, useState } from "react"; export function ParticleBackground() { const [reducedMotion, setReducedMotion] = useState(false); @@ -171,18 +15,17 @@ export function ParticleBackground() { return ( - - - + {/* Top-right glow orb */} + + {/* Bottom-left glow orb */} + ); } diff --git a/services/frontend/tailwind.config.js b/services/frontend/tailwind.config.js index 3ce7f27..836e014 100644 --- a/services/frontend/tailwind.config.js +++ b/services/frontend/tailwind.config.js @@ -3,21 +3,24 @@ export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], theme: { extend: { + fontFamily: { + sans: ["Poppins", "ui-sans-serif", "system-ui", "-apple-system", "sans-serif"], + }, colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - "primary-soft": "hsl(var(--primary-soft))", - "primary-glow": "hsl(var(--primary-glow))", - "accent-glow": "hsl(var(--accent-glow))", - primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" }, - secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" }, - muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" }, - accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" }, - destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" }, - card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" }, + border: "oklch(var(--border))", + input: "oklch(var(--input))", + ring: "oklch(var(--ring))", + background: "oklch(var(--background))", + foreground: "oklch(var(--foreground))", + "primary-soft": "oklch(var(--primary-soft))", + "primary-glow": "oklch(var(--primary-glow))", + "accent-glow": "oklch(var(--accent-glow))", + primary: { DEFAULT: "oklch(var(--primary))", foreground: "oklch(var(--primary-foreground))" }, + secondary: { DEFAULT: "oklch(var(--secondary))", foreground: "oklch(var(--secondary-foreground))" }, + muted: { DEFAULT: "oklch(var(--muted))", foreground: "oklch(var(--muted-foreground))" }, + accent: { DEFAULT: "oklch(var(--accent))", foreground: "oklch(var(--accent-foreground))" }, + destructive: { DEFAULT: "oklch(var(--destructive))", foreground: "oklch(var(--destructive-foreground))" }, + card: { DEFAULT: "oklch(var(--card))", foreground: "oklch(var(--card-foreground))" }, }, borderRadius: { lg: "var(--radius)", @@ -27,6 +30,9 @@ export default { animation: { shimmer: "shimmer 2s linear infinite", "bar-pulse": "bar-pulse 0.4s ease-in-out infinite", + "fade-in-up": "fadeInUp 0.5s ease-out", + "fade-in": "fadeIn 0.3s ease-out", + "glow-pulse": "glowPulse 3s ease-in-out infinite", }, keyframes: { shimmer: { @@ -37,6 +43,18 @@ export default { "0%, 100%": { transform: "scaleY(0.8)" }, "50%": { transform: "scaleY(1.2)" }, }, + fadeInUp: { + "0%": { opacity: "0", transform: "translateY(20px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, + fadeIn: { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, + glowPulse: { + "0%, 100%": { opacity: "0.4" }, + "50%": { opacity: "0.8" }, + }, }, }, },