feat(ui): overhaul frontend with IMPHNEN visual identity
- Update CSS foundation: HSL→oklch color tokens, Poppins font, new utilities - Replace Three.js sakura particles with CSS glow orbs - Rebrand Header with IMPHNEN logo and gradient text - Update all UI components (Button, Card, Badge, Input, Select, Tabs, Toast) - Apply IMPHNEN design patterns (glass, gradient, grid) to layout - Remove all hardcoded #7EC8E3/#FFB7C5/#FCA5A5 references - Standardize rounded-xl across all components - Replace emoji toasts with Lucide icons Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GMW — Discord Moderation Watcher</title>
|
||||
<meta name="theme-color" content="#23a1eb" />
|
||||
<title>IMPHNEN — Discord Moderation</title>
|
||||
<link rel="icon" type="image/svg+xml" href="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -18,12 +18,12 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-xl border border-sky-200 bg-white p-3"
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3"
|
||||
>
|
||||
<img
|
||||
src={s.avatar}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded-full object-cover ring-2 ring-[#7EC8E3]/40"
|
||||
className="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||
|
||||
@@ -21,10 +21,10 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
// Sky-blue to pink gradient
|
||||
// IMPHNEN blue gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, "#7EC8E3");
|
||||
gradient.addColorStop(1, "#FFB7C5");
|
||||
gradient.addColorStop(0, "#23a1eb");
|
||||
gradient.addColorStop(1, "#3eb0f2");
|
||||
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const level = levels[i];
|
||||
@@ -54,7 +54,7 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
ref={canvasRef}
|
||||
width={512}
|
||||
height={128}
|
||||
className="w-full rounded-xl bg-sky-50/30"
|
||||
className="w-full rounded-lg bg-primary/5"
|
||||
style={{ height: "128px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function MusicSubPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-sky-200 bg-white p-4 shadow-md space-y-4">
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
@@ -51,7 +51,7 @@ export function MusicSubPanel({
|
||||
placeholder="YouTube URL, Spotify track, or search terms"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-[#7EC8E3]" />
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-primary" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
@@ -59,18 +59,14 @@ export function MusicSubPanel({
|
||||
step={1}
|
||||
value={draftVolume}
|
||||
onChange={(e) => setDraftVolume(Number(e.target.value))}
|
||||
className="h-2 w-full cursor-pointer accent-[#7EC8E3]"
|
||||
className="h-2 w-full cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
{draftVolume}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80"
|
||||
disabled={loading || !source.trim()}
|
||||
onClick={submit}
|
||||
>
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<Music2 className="mr-1.5 h-4 w-4" /> Queue
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
|
||||
@@ -11,10 +11,10 @@ export function NowPlaying({ current, queue }: NowPlayingProps) {
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-sky-200 bg-white shadow-md">
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#7EC8E3]/15 text-[#7EC8E3]">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{current.mode === "screen" ? (
|
||||
<MonitorUp className="h-5 w-5" />
|
||||
) : (
|
||||
@@ -34,15 +34,15 @@ export function NowPlaying({ current, queue }: NowPlayingProps) {
|
||||
</div>
|
||||
|
||||
{queue.length > 0 && (
|
||||
<div className="border-t border-sky-100 p-4">
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
|
||||
<div className="space-y-1.5">
|
||||
{queue.map((item, i) => (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg border-l-2 border-l-[#7EC8E3] border border-sky-200 bg-white p-2.5 text-sm"
|
||||
className="flex items-center gap-3 rounded-lg border-l-2 border-l-primary border-border bg-card p-2.5 text-sm"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#7EC8E3]/15 text-xs font-medium text-[#7EC8E3]">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -42,7 +42,7 @@ export function RecordingsSubPanel() {
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 rounded-xl border border-sky-200 bg-white p-4"
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-card p-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
@@ -83,7 +83,7 @@ export function RecordingsSubPanel() {
|
||||
key={rec.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-sky-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#7EC8E3]/15 text-[#7EC8E3]">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -120,7 +120,7 @@ export function RecordingsSubPanel() {
|
||||
href={rec.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg bg-[#7EC8E3] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#7EC8E3]/80"
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ScreenSubPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-sky-200 bg-white p-4 shadow-md space-y-4">
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
@@ -32,11 +32,7 @@ export function ScreenSubPanel({
|
||||
placeholder="Screen share URL or local file path"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80"
|
||||
disabled={loading || !source.trim()}
|
||||
onClick={submit}
|
||||
>
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
|
||||
@@ -36,10 +36,10 @@ export function VoiceConnectionCard({
|
||||
onStreamingToggle,
|
||||
}: VoiceConnectionCardProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-sky-200 bg-white shadow-md">
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="p-6">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||
<Radio className="h-5 w-5 text-[#7EC8E3]" /> Voice Bridge
|
||||
<Radio className="h-5 w-5 text-primary" /> Voice Bridge
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Join a Discord voice channel, listen, and transmit audio.
|
||||
@@ -75,7 +75,7 @@ export function VoiceConnectionCard({
|
||||
<Button
|
||||
disabled={!selectedGuild || !selectedChannel || voiceLoading}
|
||||
onClick={onJoin}
|
||||
className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{status.connected ? "Reconnect" : "Join Voice"}
|
||||
</Button>
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ImageItem {
|
||||
function kindBadge(kind: ImageItem["kind"]): string {
|
||||
switch (kind) {
|
||||
case "sticker":
|
||||
return "bg-pink-100 text-pink-700 border-pink-200";
|
||||
return "bg-primary/10 text-primary border-primary/20";
|
||||
case "attachment":
|
||||
return "bg-primary-soft text-primary border-primary/30";
|
||||
case "embed":
|
||||
@@ -82,7 +82,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group overflow-hidden rounded-2xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||
className="group overflow-hidden rounded-xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
{image.kind === "sticker" ? (
|
||||
|
||||
@@ -376,8 +376,8 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group rounded-2xl border bg-white shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
||||
firstMsg.deleted_at ? "border-red-200 opacity-60" : "border-primary/20"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 p-4">
|
||||
@@ -437,7 +437,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||
|
||||
export function MessageCardSkeleton() {
|
||||
return (
|
||||
<article className="rounded-2xl border border-primary/20 bg-white p-4 shadow-sm">
|
||||
<article className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
|
||||
@@ -103,7 +103,7 @@ export function MessagesPanel({
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={cardItem}>
|
||||
<Card className="border-primary/20">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Messages</CardTitle>
|
||||
{guildName && (
|
||||
@@ -141,7 +141,7 @@ export function MessagesPanel({
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-pink-100 text-pink-700 border-pink-200"
|
||||
className="text-xs bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
{stats.flagged} flagged
|
||||
</Badge>
|
||||
@@ -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"
|
||||
>
|
||||
<RotateCw
|
||||
className={`mr-1.5 h-3.5 w-3.5 ${retryingAll ? "animate-spin" : ""}`}
|
||||
@@ -248,7 +248,7 @@ export function MessagesPanel({
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
aiFilter === f
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-primary-soft"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
|
||||
@@ -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({
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{originalFlags.map((f) => (
|
||||
<Badge key={f} variant="destructive" className="text-[10px] capitalize">
|
||||
<Badge
|
||||
key={f}
|
||||
variant="destructive"
|
||||
className="text-[10px] capitalize"
|
||||
>
|
||||
{f.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -53,13 +64,20 @@ function CorrectionRow({
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{isCleared ? (
|
||||
<Badge variant="success" className="text-[10px] bg-emerald-100 text-emerald-800 border-emerald-200">
|
||||
<Badge
|
||||
variant="success"
|
||||
className="text-[10px] bg-emerald-100 text-emerald-800 border-emerald-200"
|
||||
>
|
||||
Cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{correctedFlags.map((f) => (
|
||||
<Badge key={f} variant="outline" className="text-[10px] capitalize">
|
||||
<Badge
|
||||
key={f}
|
||||
variant="outline"
|
||||
className="text-[10px] capitalize"
|
||||
>
|
||||
{f.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 rounded-2xl" />
|
||||
<Skeleton className="h-12 rounded-2xl" />
|
||||
<Skeleton className="h-12 rounded-2xl" />
|
||||
<Skeleton className="h-12 rounded-2xl" />
|
||||
<Skeleton className="h-12 rounded-2xl" />
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="rounded-2xl border-red-200 bg-red-50">
|
||||
<Card className="rounded-xl border-red-200 bg-red-50">
|
||||
<CardContent className="flex items-center gap-3 py-6">
|
||||
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||
<p className="flex-1 text-sm text-red-700">{error}</p>
|
||||
@@ -112,11 +131,12 @@ export function CorrectionHistoryContent() {
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Card className="rounded-2xl">
|
||||
<Card className="rounded-xl">
|
||||
<CardContent className="flex flex-col items-center py-12">
|
||||
<EmptyStateMascot />
|
||||
<p className="mt-4 text-sm text-muted-foreground text-center max-w-md">
|
||||
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.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -124,7 +144,7 @@ export function CorrectionHistoryContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl">
|
||||
<Card className="rounded-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Correction History
|
||||
@@ -159,7 +179,9 @@ export function CorrectionHistoryContent() {
|
||||
disabled={loadingMore}
|
||||
className="gap-1.5 text-xs"
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${loadingMore ? "animate-bounce" : ""}`} />
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${loadingMore ? "animate-bounce" : ""}`}
|
||||
/>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -20,7 +28,7 @@ function FlagsBar({ flag, count, max }: { flag: string; count: number; max: numb
|
||||
<div className="flex-1">
|
||||
<div className="h-2.5 rounded-full bg-primary/10">
|
||||
<motion.div
|
||||
className="h-2.5 rounded-full bg-gradient-to-r from-[#7EC8E3] to-pink-400"
|
||||
className="h-2.5 rounded-full bg-gradient-to-r from-primary to-blue-400"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
@@ -40,16 +48,16 @@ export function CorrectionStatsContent() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Skeleton className="h-32 rounded-2xl" />
|
||||
<Skeleton className="h-32 rounded-2xl" />
|
||||
<Skeleton className="h-32 rounded-2xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="rounded-2xl border-red-200 bg-red-50">
|
||||
<Card className="rounded-xl border-red-200 bg-red-50">
|
||||
<CardContent className="flex items-center gap-3 py-6">
|
||||
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||
<p className="flex-1 text-sm text-red-700">{error}</p>
|
||||
@@ -68,11 +76,12 @@ export function CorrectionStatsContent() {
|
||||
|
||||
if (!stats || stats.total_corrections === 0) {
|
||||
return (
|
||||
<Card className="rounded-2xl">
|
||||
<Card className="rounded-xl">
|
||||
<CardContent className="flex flex-col items-center py-12">
|
||||
<EmptyStateMascot />
|
||||
<p className="mt-4 text-sm text-muted-foreground text-center max-w-md">
|
||||
No corrections yet. When admins correct false positives, statistics will appear here.
|
||||
No corrections yet. When admins correct false positives, statistics
|
||||
will appear here.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -97,7 +106,7 @@ export function CorrectionStatsContent() {
|
||||
animate: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||
}}
|
||||
>
|
||||
<Card className="rounded-2xl">
|
||||
<Card className="rounded-xl">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Corrections
|
||||
@@ -117,7 +126,7 @@ export function CorrectionStatsContent() {
|
||||
animate: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||
}}
|
||||
>
|
||||
<Card className="rounded-2xl">
|
||||
<Card className="rounded-xl">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Last 7 Days
|
||||
@@ -134,7 +143,7 @@ export function CorrectionStatsContent() {
|
||||
|
||||
{/* Flags bar chart */}
|
||||
{stats.by_flag.length > 0 && (
|
||||
<Card className="rounded-2xl">
|
||||
<Card className="rounded-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Most Corrected Flags
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Card className="rounded-2xl">
|
||||
<Card className="rounded-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Submit Correction
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -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"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={addFlag} type="button">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addFlag}
|
||||
type="button"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [success, setSuccess] = useState<CorrectionEntry | null>(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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -15,7 +15,7 @@ interface MobileTabBarProps {
|
||||
|
||||
export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-primary/20 bg-white rounded-t-xl md:hidden">
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg md:hidden">
|
||||
{tabs.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -28,6 +28,9 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
{activeTab === id && (
|
||||
<span className="h-0.5 w-6 rounded-full bg-primary mx-auto mt-0.5" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -12,10 +12,10 @@ type BadgeVariant =
|
||||
const variants: Record<BadgeVariant, string> = {
|
||||
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<HTMLDivElement> {
|
||||
|
||||
@@ -11,17 +11,19 @@ type ButtonVariant =
|
||||
type ButtonSize = "default" | "sm" | "lg" | "icon";
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
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<ButtonSize, string> = {
|
||||
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 (
|
||||
<Comp
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
|
||||
@@ -8,7 +8,7 @@ export function Card({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-white text-card-foreground shadow-md hover:shadow-lg transition-shadow",
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function Input({ className, type, ...props }: InputProps) {
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-xl border border-border bg-white px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ function ScrollBar({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-primary/30" />
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-primary/20" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function Select({
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-xl border border-border bg-white px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function Skeleton({
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-md bg-muted animate-shimmer", className)}
|
||||
className={cn("rounded-lg bg-muted animate-shimmer", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function TabsTrigger({
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-primary-soft data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
// ─── Toast notification system ──────────────────────────────────────────────
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -58,17 +65,17 @@ export function useToast() {
|
||||
}
|
||||
|
||||
const typeStyles: Record<Toast["type"], string> = {
|
||||
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<Toast["type"], string> = {
|
||||
info: "💠",
|
||||
success: "🌸",
|
||||
error: "😿",
|
||||
warning: "⚠️",
|
||||
const typeIcons: Record<Toast["type"], React.ReactNode> = {
|
||||
info: <Info className="h-4 w-4 text-primary" />,
|
||||
success: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
|
||||
error: <AlertCircle className="h-4 w-4 text-destructive" />,
|
||||
warning: <AlertTriangle className="h-4 w-4 text-amber-500" />,
|
||||
};
|
||||
|
||||
function ToastContainer() {
|
||||
@@ -82,15 +89,14 @@ function ToastContainer() {
|
||||
<div
|
||||
key={toast.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border border-border px-4 py-3 text-sm shadow-md cursor-pointer transition-all hover:scale-[1.02] border-l-4",
|
||||
"group flex items-center gap-2.5 rounded-lg border border-border px-4 py-3 text-sm shadow-md cursor-pointer transition-all hover:scale-[1.02] border-l-4",
|
||||
typeStyles[toast.type],
|
||||
)}
|
||||
onClick={() => removeToast(toast.id)}
|
||||
>
|
||||
<span className="text-base leading-none">
|
||||
{typeIcons[toast.type]}
|
||||
</span>
|
||||
<span>{toast.message}</span>
|
||||
<span className="flex-shrink-0">{typeIcons[toast.type]}</span>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<X className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,8 +31,12 @@ export function DashboardLayout({
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-background text-foreground">
|
||||
{/* Sakura particle layer */}
|
||||
{/* Background layers */}
|
||||
<ParticleBackground />
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none grid-pattern opacity-[0.03]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative flex min-h-screen">
|
||||
<Sidebar
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Badge } from "../shared/ui";
|
||||
import type { WsStatus } from "../shared/ws/socket";
|
||||
|
||||
const titles: Record<DashboardTab, string> = {
|
||||
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 (
|
||||
<header className="sticky top-0 z-10 border-b border-[#7EC8E3]/20 bg-white/70 px-4 py-4 backdrop-blur-md md:px-8">
|
||||
<header className="sticky top-0 z-10 border-b border-border/50 bg-background/70 px-4 py-4 backdrop-blur-sm md:px-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
{/* Left: animated tab title + subtitle */}
|
||||
{/* Left: IMPHNEN brand + tab title */}
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
variants={fadeSlideUp}
|
||||
@@ -75,16 +75,21 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||
animate="animate"
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
||||
alt="IMPHNEN"
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
<span className="text-[#7EC8E3]">GMW</span>
|
||||
<span className="gradient-text">IMPHNEN</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
{titles[activeTab]}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{subtitles[activeTab]}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground hidden md:block">
|
||||
{subtitles[activeTab]}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Right: status badges */}
|
||||
@@ -92,7 +97,7 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||
{/* WS Badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-[#7EC8E3]/20 bg-white/50 px-3 py-1.5 text-xs text-muted-foreground"
|
||||
className="border-border bg-card/50 px-3 py-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{wsStatus === "connected" ? (
|
||||
<Wifi className="mr-1.5 h-3 w-3 text-emerald-400" />
|
||||
@@ -106,10 +111,8 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-[#7EC8E3]/20 bg-white/50 px-3 py-1.5 text-xs",
|
||||
voiceStatus.connected
|
||||
? "text-[#7EC8E3]"
|
||||
: "text-muted-foreground",
|
||||
"border-border bg-card/50 px-3 py-1.5 text-xs",
|
||||
voiceStatus.connected ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<VoiceIndicator voiceStatus={voiceStatus} />
|
||||
|
||||
@@ -45,7 +45,7 @@ export function Sidebar({
|
||||
<>
|
||||
<motion.nav
|
||||
className={cn(
|
||||
"relative hidden shrink-0 flex-col overflow-visible border-r border-[#7EC8E3]/20 bg-white/70 backdrop-blur-md transition-all duration-300 md:flex",
|
||||
"relative hidden shrink-0 flex-col overflow-visible border-r border-border/50 bg-background/70 backdrop-blur-sm transition-all duration-300 md:flex",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
layout
|
||||
@@ -60,7 +60,7 @@ export function Sidebar({
|
||||
>
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
||||
alt="GMW"
|
||||
alt="IMPHNEN"
|
||||
className="h-8 w-8 rounded-xl"
|
||||
/>
|
||||
|
||||
@@ -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)";
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
@@ -114,7 +107,7 @@ export function Sidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<MascotImage size="sm" />
|
||||
@@ -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]"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gradient-to-b from-white to-primary/5">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-card">
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
@@ -177,7 +177,7 @@ export function MascotChatbot({
|
||||
className={cn(
|
||||
"max-w-xs px-3 py-2 rounded-xl text-sm break-words",
|
||||
message.role === "user"
|
||||
? "bg-primary text-white rounded-br-none"
|
||||
? "bg-primary text-primary-foreground rounded-br-none"
|
||||
: "bg-muted text-foreground rounded-bl-none",
|
||||
)}
|
||||
>
|
||||
@@ -231,7 +231,7 @@ export function MascotChatbot({
|
||||
{/* Input */}
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="border-t border-primary/10 p-3 bg-white"
|
||||
className="border-t border-border p-3 bg-card"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -240,14 +240,14 @@ export function MascotChatbot({
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="submit"
|
||||
disabled={loading || !input.trim()}
|
||||
className="p-2 bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="p-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</motion.button>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function MascotImage({
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Chat bubble */}
|
||||
<div className="bg-gradient-to-br from-primary/90 to-primary/80 text-white rounded-2xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/50">
|
||||
<div className="bg-primary/90 text-primary-foreground rounded-xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-white/80" />
|
||||
<p className="text-xs leading-relaxed font-medium line-clamp-3">
|
||||
|
||||
@@ -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 (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={[data.x, data.y, data.z]}
|
||||
scale={[data.scale, data.scale, data.scale]}
|
||||
>
|
||||
<planeGeometry args={[1, 1]} />
|
||||
<meshStandardMaterial
|
||||
color={`hsl(${data.hue}, ${data.sat}%, ${data.light}%)`}
|
||||
transparent
|
||||
opacity={0.65}
|
||||
side={2}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<>
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={0.3} />
|
||||
|
||||
{petals.map((data, i) => (
|
||||
<Petal key={i} data={data} mouseRef={mouseRef} />
|
||||
))}
|
||||
|
||||
<Sparkles
|
||||
count={sparkleCount}
|
||||
scale={12}
|
||||
size={0.8}
|
||||
speed={0.2}
|
||||
color="#7EC8E3"
|
||||
opacity={0.5}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Exported component ──────────────────────────────────────────────────────
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ParticleBackground() {
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
@@ -171,18 +15,17 @@ export function ParticleBackground() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
className="fixed inset-0 pointer-events-none overflow-hidden"
|
||||
aria-hidden="true"
|
||||
style={{ zIndex: -1, isolation: "isolate" }}
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 8], fov: 75 }}
|
||||
dpr={[1, 2]}
|
||||
gl={{ antialias: false, alpha: true }}
|
||||
style={{ background: "transparent", width: "100%", height: "100%" }}
|
||||
>
|
||||
<ParticleScene />
|
||||
</Canvas>
|
||||
{/* Top-right glow orb */}
|
||||
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full bg-primary/10 blur-3xl animate-glow-pulse" />
|
||||
{/* Bottom-left glow orb */}
|
||||
<div
|
||||
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full bg-blue-400/10 blur-3xl animate-glow-pulse"
|
||||
style={{ animationDelay: "1.5s" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user