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:
MythEclipse
2026-06-12 23:00:52 +07:00
co-authored by Claude
parent 7f3eb7b9ac
commit 5c00c9f60b
34 changed files with 341 additions and 394 deletions
+6 -2
View File
@@ -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>
+3 -3
View File
@@ -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> {
+10 -8
View File
@@ -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,
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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>
);
}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
/>
);
+1 -1
View File
@@ -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}
+20 -14
View File
@@ -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>
+58 -39
View File
@@ -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
+17 -14
View File
@@ -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} />
+6 -13
View File
@@ -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>
);
}
+32 -14
View File
@@ -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" },
},
},
},
},