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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="id">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
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
|
<img
|
||||||
src={s.avatar}
|
src={s.avatar}
|
||||||
alt=""
|
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="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
<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 barWidth = width / levels.length;
|
||||||
const maxBarHeight = height * 0.85;
|
const maxBarHeight = height * 0.85;
|
||||||
|
|
||||||
// Sky-blue to pink gradient
|
// IMPHNEN blue gradient
|
||||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
gradient.addColorStop(0, "#7EC8E3");
|
gradient.addColorStop(0, "#23a1eb");
|
||||||
gradient.addColorStop(1, "#FFB7C5");
|
gradient.addColorStop(1, "#3eb0f2");
|
||||||
|
|
||||||
for (let i = 0; i < levels.length; i++) {
|
for (let i = 0; i < levels.length; i++) {
|
||||||
const level = levels[i];
|
const level = levels[i];
|
||||||
@@ -54,7 +54,7 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
|||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={512}
|
width={512}
|
||||||
height={128}
|
height={128}
|
||||||
className="w-full rounded-xl bg-sky-50/30"
|
className="w-full rounded-lg bg-primary/5"
|
||||||
style={{ height: "128px" }}
|
style={{ height: "128px" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function MusicSubPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<Input
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(e) => setSource(e.target.value)}
|
onChange={(e) => setSource(e.target.value)}
|
||||||
@@ -51,7 +51,7 @@ export function MusicSubPanel({
|
|||||||
placeholder="YouTube URL, Spotify track, or search terms"
|
placeholder="YouTube URL, Spotify track, or search terms"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -59,18 +59,14 @@ export function MusicSubPanel({
|
|||||||
step={1}
|
step={1}
|
||||||
value={draftVolume}
|
value={draftVolume}
|
||||||
onChange={(e) => setDraftVolume(Number(e.target.value))}
|
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">
|
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||||
{draftVolume}%
|
{draftVolume}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||||
className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80"
|
|
||||||
disabled={loading || !source.trim()}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
<Music2 className="mr-1.5 h-4 w-4" /> Queue
|
<Music2 className="mr-1.5 h-4 w-4" /> Queue
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export function NowPlaying({ current, queue }: NowPlayingProps) {
|
|||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
return (
|
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="p-4">
|
||||||
<div className="flex items-center gap-3">
|
<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" ? (
|
{current.mode === "screen" ? (
|
||||||
<MonitorUp className="h-5 w-5" />
|
<MonitorUp className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
@@ -34,15 +34,15 @@ export function NowPlaying({ current, queue }: NowPlayingProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{queue.length > 0 && (
|
{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="mb-2 text-sm font-medium">Queue ({queue.length})</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{queue.map((item, i) => (
|
{queue.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.source}-${i}`}
|
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}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function RecordingsSubPanel() {
|
|||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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" />
|
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
@@ -83,7 +83,7 @@ export function RecordingsSubPanel() {
|
|||||||
key={rec.id}
|
key={rec.id}
|
||||||
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-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" />
|
<Mic className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -120,7 +120,7 @@ export function RecordingsSubPanel() {
|
|||||||
href={rec.download_url}
|
href={rec.download_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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" />
|
<Download className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function ScreenSubPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<Input
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(e) => setSource(e.target.value)}
|
onChange={(e) => setSource(e.target.value)}
|
||||||
@@ -32,11 +32,7 @@ export function ScreenSubPanel({
|
|||||||
placeholder="Screen share URL or local file path"
|
placeholder="Screen share URL or local file path"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||||
className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80"
|
|
||||||
disabled={loading || !source.trim()}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
|
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ export function VoiceConnectionCard({
|
|||||||
onStreamingToggle,
|
onStreamingToggle,
|
||||||
}: VoiceConnectionCardProps) {
|
}: VoiceConnectionCardProps) {
|
||||||
return (
|
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">
|
<div className="p-6">
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
<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>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Join a Discord voice channel, listen, and transmit audio.
|
Join a Discord voice channel, listen, and transmit audio.
|
||||||
@@ -75,7 +75,7 @@ export function VoiceConnectionCard({
|
|||||||
<Button
|
<Button
|
||||||
disabled={!selectedGuild || !selectedChannel || voiceLoading}
|
disabled={!selectedGuild || !selectedChannel || voiceLoading}
|
||||||
onClick={onJoin}
|
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"}
|
{status.connected ? "Reconnect" : "Join Voice"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface ImageItem {
|
|||||||
function kindBadge(kind: ImageItem["kind"]): string {
|
function kindBadge(kind: ImageItem["kind"]): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "sticker":
|
case "sticker":
|
||||||
return "bg-pink-100 text-pink-700 border-pink-200";
|
return "bg-primary/10 text-primary border-primary/20";
|
||||||
case "attachment":
|
case "attachment":
|
||||||
return "bg-primary-soft text-primary border-primary/30";
|
return "bg-primary-soft text-primary border-primary/30";
|
||||||
case "embed":
|
case "embed":
|
||||||
@@ -82,7 +82,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
|||||||
href={image.url}
|
href={image.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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">
|
<div className="relative aspect-video overflow-hidden">
|
||||||
{image.kind === "sticker" ? (
|
{image.kind === "sticker" ? (
|
||||||
|
|||||||
@@ -376,8 +376,8 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`group rounded-2xl border bg-white shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
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-primary/20"
|
firstMsg.deleted_at ? "border-red-200 opacity-60" : "border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3 p-4">
|
<div className="flex gap-3 p-4">
|
||||||
@@ -437,7 +437,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
|||||||
|
|
||||||
export function MessageCardSkeleton() {
|
export function MessageCardSkeleton() {
|
||||||
return (
|
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">
|
<div className="flex gap-3">
|
||||||
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
||||||
<div className="min-w-0 flex-1 space-y-3">
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function MessagesPanel({
|
|||||||
animate="animate"
|
animate="animate"
|
||||||
>
|
>
|
||||||
<motion.div variants={cardItem}>
|
<motion.div variants={cardItem}>
|
||||||
<Card className="border-primary/20">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-primary">Messages</CardTitle>
|
<CardTitle className="text-primary">Messages</CardTitle>
|
||||||
{guildName && (
|
{guildName && (
|
||||||
@@ -141,7 +141,7 @@ export function MessagesPanel({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
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
|
{stats.flagged} flagged
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -224,7 +224,7 @@ export function MessagesPanel({
|
|||||||
setRetryingAll(false);
|
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
|
<RotateCw
|
||||||
className={`mr-1.5 h-3.5 w-3.5 ${retryingAll ? "animate-spin" : ""}`}
|
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 ${
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||||
aiFilter === f
|
aiFilter === f
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
? "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}
|
{f}
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ function CorrectionRow({
|
|||||||
entry,
|
entry,
|
||||||
index,
|
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;
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
const originalFlags = parseFlags(entry.original_flags);
|
const originalFlags = parseFlags(entry.original_flags);
|
||||||
@@ -45,7 +52,11 @@ function CorrectionRow({
|
|||||||
<td className="py-3 pr-4">
|
<td className="py-3 pr-4">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{originalFlags.map((f) => (
|
{originalFlags.map((f) => (
|
||||||
<Badge key={f} variant="destructive" className="text-[10px] capitalize">
|
<Badge
|
||||||
|
key={f}
|
||||||
|
variant="destructive"
|
||||||
|
className="text-[10px] capitalize"
|
||||||
|
>
|
||||||
{f.replace(/_/g, " ")}
|
{f.replace(/_/g, " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@@ -53,13 +64,20 @@ function CorrectionRow({
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4">
|
<td className="py-3 pr-4">
|
||||||
{isCleared ? (
|
{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
|
Cleared
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{correctedFlags.map((f) => (
|
{correctedFlags.map((f) => (
|
||||||
<Badge key={f} variant="outline" className="text-[10px] capitalize">
|
<Badge
|
||||||
|
key={f}
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] capitalize"
|
||||||
|
>
|
||||||
{f.replace(/_/g, " ")}
|
{f.replace(/_/g, " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@@ -77,23 +95,24 @@ function CorrectionRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CorrectionHistoryContent() {
|
export function CorrectionHistoryContent() {
|
||||||
const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = useCorrectionHistory();
|
const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } =
|
||||||
|
useCorrectionHistory();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-12 rounded-2xl" />
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
<Skeleton className="h-12 rounded-2xl" />
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
<Skeleton className="h-12 rounded-2xl" />
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
<Skeleton className="h-12 rounded-2xl" />
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
<Skeleton className="h-12 rounded-2xl" />
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<CardContent className="flex items-center gap-3 py-6">
|
||||||
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
<p className="flex-1 text-sm text-red-700">{error}</p>
|
<p className="flex-1 text-sm text-red-700">{error}</p>
|
||||||
@@ -112,11 +131,12 @@ export function CorrectionHistoryContent() {
|
|||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-xl">
|
||||||
<CardContent className="flex flex-col items-center py-12">
|
<CardContent className="flex flex-col items-center py-12">
|
||||||
<EmptyStateMascot />
|
<EmptyStateMascot />
|
||||||
<p className="mt-4 text-sm text-muted-foreground text-center max-w-md">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -124,7 +144,7 @@ export function CorrectionHistoryContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Correction History
|
Correction History
|
||||||
@@ -159,7 +179,9 @@ export function CorrectionHistoryContent() {
|
|||||||
disabled={loadingMore}
|
disabled={loadingMore}
|
||||||
className="gap-1.5 text-xs"
|
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"}
|
{loadingMore ? "Loading..." : "Load More"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import {
|
|||||||
} from "../../../shared/ui";
|
} from "../../../shared/ui";
|
||||||
import { EmptyStateMascot } 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;
|
const pct = max > 0 ? (count / max) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<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="flex-1">
|
||||||
<div className="h-2.5 rounded-full bg-primary/10">
|
<div className="h-2.5 rounded-full bg-primary/10">
|
||||||
<motion.div
|
<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 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: `${pct}%` }}
|
animate={{ width: `${pct}%` }}
|
||||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
@@ -40,16 +48,16 @@ export function CorrectionStatsContent() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Skeleton className="h-32 rounded-2xl" />
|
<Skeleton className="h-32 rounded-xl" />
|
||||||
<Skeleton className="h-32 rounded-2xl" />
|
<Skeleton className="h-32 rounded-xl" />
|
||||||
<Skeleton className="h-32 rounded-2xl" />
|
<Skeleton className="h-32 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<CardContent className="flex items-center gap-3 py-6">
|
||||||
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
<p className="flex-1 text-sm text-red-700">{error}</p>
|
<p className="flex-1 text-sm text-red-700">{error}</p>
|
||||||
@@ -68,11 +76,12 @@ export function CorrectionStatsContent() {
|
|||||||
|
|
||||||
if (!stats || stats.total_corrections === 0) {
|
if (!stats || stats.total_corrections === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-xl">
|
||||||
<CardContent className="flex flex-col items-center py-12">
|
<CardContent className="flex flex-col items-center py-12">
|
||||||
<EmptyStateMascot />
|
<EmptyStateMascot />
|
||||||
<p className="mt-4 text-sm text-muted-foreground text-center max-w-md">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -97,7 +106,7 @@ export function CorrectionStatsContent() {
|
|||||||
animate: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
animate: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-xl">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Total Corrections
|
Total Corrections
|
||||||
@@ -117,7 +126,7 @@ export function CorrectionStatsContent() {
|
|||||||
animate: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
animate: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-xl">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Last 7 Days
|
Last 7 Days
|
||||||
@@ -134,7 +143,7 @@ export function CorrectionStatsContent() {
|
|||||||
|
|
||||||
{/* Flags bar chart */}
|
{/* Flags bar chart */}
|
||||||
{stats.by_flag.length > 0 && (
|
{stats.by_flag.length > 0 && (
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Most Corrected Flags
|
Most Corrected Flags
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export function SubmitCorrectionContent() {
|
|||||||
content_snippet: contentSnippet.trim(),
|
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
|
// Reset form
|
||||||
setMessageId("");
|
setMessageId("");
|
||||||
@@ -86,14 +89,14 @@ export function SubmitCorrectionContent() {
|
|||||||
transition={{ duration: 0.4 }}
|
transition={{ duration: 0.4 }}
|
||||||
className="max-w-2xl"
|
className="max-w-2xl"
|
||||||
>
|
>
|
||||||
<Card className="rounded-2xl">
|
<Card className="rounded-xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Submit Correction
|
Submit Correction
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Record a false positive — a message that was incorrectly flagged by AI moderation.
|
Record a false positive — a message that was incorrectly flagged by
|
||||||
This helps the system learn and improve accuracy.
|
AI moderation. This helps the system learn and improve accuracy.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -134,10 +137,20 @@ export function SubmitCorrectionContent() {
|
|||||||
placeholder="e.g. sexual_deviation"
|
placeholder="e.g. sexual_deviation"
|
||||||
value={flagInput}
|
value={flagInput}
|
||||||
onChange={(e) => setFlagInput(e.target.value)}
|
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"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="sm" onClick={addFlag} type="button">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addFlag}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export function useCorrectionStats() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { fetch().catch(() => undefined); }, [fetch]);
|
useEffect(() => {
|
||||||
|
fetch().catch(() => undefined);
|
||||||
|
}, [fetch]);
|
||||||
|
|
||||||
return { stats, loading, error, refetch: fetch };
|
return { stats, loading, error, refetch: fetch };
|
||||||
}
|
}
|
||||||
@@ -51,7 +53,9 @@ export function useCorrectionHistory() {
|
|||||||
cursorRef.current = result.nextCursor;
|
cursorRef.current = result.nextCursor;
|
||||||
hasMoreRef.current = result.nextCursor !== null;
|
hasMoreRef.current = result.nextCursor !== null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load corrections");
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load corrections",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,7 +65,10 @@ export function useCorrectionHistory() {
|
|||||||
if (!cursorRef.current || loadingMore) return;
|
if (!cursorRef.current || loadingMore) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const result = await listCorrections({ limit: 20, cursor: cursorRef.current });
|
const result = await listCorrections({
|
||||||
|
limit: 20,
|
||||||
|
cursor: cursorRef.current,
|
||||||
|
});
|
||||||
setEntries((prev) => [...prev, ...result.data]);
|
setEntries((prev) => [...prev, ...result.data]);
|
||||||
cursorRef.current = result.nextCursor;
|
cursorRef.current = result.nextCursor;
|
||||||
hasMoreRef.current = result.nextCursor !== null;
|
hasMoreRef.current = result.nextCursor !== null;
|
||||||
@@ -72,9 +79,19 @@ export function useCorrectionHistory() {
|
|||||||
}
|
}
|
||||||
}, [loadingMore]);
|
}, [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 ─────────────────────────────────────────────────────────────────
|
// ─── Submit ─────────────────────────────────────────────────────────────────
|
||||||
@@ -84,7 +101,8 @@ export function useSubmitCorrection() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<CorrectionEntry | null>(null);
|
const [success, setSuccess] = useState<CorrectionEntry | null>(null);
|
||||||
|
|
||||||
const submit = useCallback(async (data: {
|
const submit = useCallback(
|
||||||
|
async (data: {
|
||||||
message_id: string;
|
message_id: string;
|
||||||
original_flags: string[];
|
original_flags: string[];
|
||||||
corrected_flags: string[];
|
corrected_flags: string[];
|
||||||
@@ -99,13 +117,16 @@ export function useSubmitCorrection() {
|
|||||||
setSuccess(result);
|
setSuccess(result);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : "Failed to submit correction";
|
const msg =
|
||||||
|
err instanceof Error ? err.message : "Failed to submit correction";
|
||||||
setError(msg);
|
setError(msg);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "../../shared/ui";
|
|
||||||
import { CorrectionStatsContent } from "./components/CorrectionStats";
|
import { CorrectionStatsContent } from "./components/CorrectionStats";
|
||||||
import { CorrectionHistoryContent } from "./components/CorrectionHistory";
|
import { CorrectionHistoryContent } from "./components/CorrectionHistory";
|
||||||
import { SubmitCorrectionContent } from "./components/SubmitCorrection";
|
import { SubmitCorrectionContent } from "./components/SubmitCorrection";
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface MobileTabBarProps {
|
|||||||
|
|
||||||
export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||||
return (
|
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 }) => (
|
{tabs.map(({ id, label, Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
@@ -28,6 +28,9 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
|||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
<span className="text-[10px]">{label}</span>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ type BadgeVariant =
|
|||||||
const variants: Record<BadgeVariant, string> = {
|
const variants: Record<BadgeVariant, string> = {
|
||||||
default: "border-transparent bg-primary text-primary-foreground",
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
secondary: "border-transparent bg-muted text-muted-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",
|
outline: "border-border text-foreground",
|
||||||
success: "border-transparent bg-[#BBF7D0] text-green-800",
|
success: "border-transparent bg-emerald-100 text-emerald-700",
|
||||||
warning: "border-transparent bg-[#FDE68A] text-amber-800",
|
warning: "border-transparent bg-amber-100 text-amber-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
|||||||
@@ -11,17 +11,19 @@ type ButtonVariant =
|
|||||||
type ButtonSize = "default" | "sm" | "lg" | "icon";
|
type ButtonSize = "default" | "sm" | "lg" | "icon";
|
||||||
|
|
||||||
const variants: Record<ButtonVariant, string> = {
|
const variants: Record<ButtonVariant, string> = {
|
||||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||||
secondary: "bg-[#FFB7C5] text-[#1a1a2e] hover:bg-[#FFB7C5]/80",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive: "bg-[#FCA5A5] text-[#1a1a2e] hover:bg-[#FCA5A5]/80",
|
destructive:
|
||||||
outline: "border border-border bg-white hover:bg-primary-soft",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
ghost: "hover:bg-primary-soft",
|
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> = {
|
const sizes: Record<ButtonSize, string> = {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
sm: "h-9 rounded-xl px-3",
|
sm: "h-9 rounded-lg px-3",
|
||||||
lg: "h-11 rounded-xl px-8",
|
lg: "h-11 rounded-lg px-8",
|
||||||
icon: "h-10 w-10",
|
icon: "h-10 w-10",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ export function Button({
|
|||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(
|
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],
|
variants[variant],
|
||||||
sizes[size],
|
sizes[size],
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function Card({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function Input({ className, type, ...props }: InputProps) {
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function ScrollBar({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...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>
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function Select({
|
|||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function Skeleton({
|
|||||||
}: HTMLAttributes<HTMLDivElement>) {
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("rounded-md bg-muted animate-shimmer", className)}
|
className={cn("rounded-lg bg-muted animate-shimmer", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function TabsTrigger({
|
|||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
// ─── Toast notification system ──────────────────────────────────────────────
|
// ─── Toast notification system ──────────────────────────────────────────────
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Info,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -58,17 +65,17 @@ export function useToast() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const typeStyles: Record<Toast["type"], string> = {
|
const typeStyles: Record<Toast["type"], string> = {
|
||||||
info: "border-l-primary bg-white text-foreground",
|
info: "border-l-primary bg-card text-card-foreground",
|
||||||
success: "border-l-green-500 bg-white text-foreground",
|
success: "border-l-emerald-500 bg-card text-card-foreground",
|
||||||
error: "border-l-red-500 bg-white text-foreground",
|
error: "border-l-destructive bg-card text-card-foreground",
|
||||||
warning: "border-l-amber-500 bg-white text-foreground",
|
warning: "border-l-amber-500 bg-card text-card-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeIcons: Record<Toast["type"], string> = {
|
const typeIcons: Record<Toast["type"], React.ReactNode> = {
|
||||||
info: "💠",
|
info: <Info className="h-4 w-4 text-primary" />,
|
||||||
success: "🌸",
|
success: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
|
||||||
error: "😿",
|
error: <AlertCircle className="h-4 w-4 text-destructive" />,
|
||||||
warning: "⚠️",
|
warning: <AlertTriangle className="h-4 w-4 text-amber-500" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function ToastContainer() {
|
function ToastContainer() {
|
||||||
@@ -82,15 +89,14 @@ function ToastContainer() {
|
|||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
className={cn(
|
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],
|
typeStyles[toast.type],
|
||||||
)}
|
)}
|
||||||
onClick={() => removeToast(toast.id)}
|
onClick={() => removeToast(toast.id)}
|
||||||
>
|
>
|
||||||
<span className="text-base leading-none">
|
<span className="flex-shrink-0">{typeIcons[toast.type]}</span>
|
||||||
{typeIcons[toast.type]}
|
<span className="flex-1">{toast.message}</span>
|
||||||
</span>
|
<X className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<span>{toast.message}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,40 +3,40 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 98%;
|
--background: 1 0 0;
|
||||||
--foreground: 222 20% 12%;
|
--foreground: 0.141 0.005 285.823;
|
||||||
--card: 0 0% 100%;
|
--card: 1 0 0;
|
||||||
--card-foreground: 222 20% 12%;
|
--card-foreground: 0.141 0.005 285.823;
|
||||||
--primary: 199 80% 50%;
|
--primary: 0.623 0.214 259.815;
|
||||||
--primary-soft: 199 80% 92%;
|
--primary-soft: 0.92 0.04 259.815;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0.97 0.014 254.604;
|
||||||
--secondary: 210 20% 94%;
|
--secondary: 0.967 0.001 286.375;
|
||||||
--secondary-foreground: 222 20% 12%;
|
--secondary-foreground: 0.21 0.006 285.885;
|
||||||
--muted: 210 20% 94%;
|
--muted: 0.967 0.001 286.375;
|
||||||
--muted-foreground: 215 16% 47%;
|
--muted-foreground: 0.552 0.016 285.938;
|
||||||
--accent: 340 80% 65%;
|
--accent: 0.967 0.001 286.375;
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent-foreground: 0.21 0.006 285.885;
|
||||||
--destructive: 0 72% 55%;
|
--destructive: 0.577 0.245 27.325;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0.97 0.014 254.604;
|
||||||
--border: 199 30% 85%;
|
--border: 0.92 0.004 286.32;
|
||||||
--input: 199 30% 85%;
|
--input: 0.92 0.004 286.32;
|
||||||
--ring: 199 80% 50%;
|
--ring: 0.623 0.214 259.815;
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
--primary-glow: 199 80% 50% / 0.15;
|
--primary-glow: 0.623 0.214 259.815 / 0.15;
|
||||||
--accent-glow: 340 80% 65% / 0.15;
|
--accent-glow: 0.552 0.016 285.938 / 0.15;
|
||||||
--card-shadow: 199 30% 70% / 0.12;
|
--card-shadow: 0.92 0.004 286.32 / 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
border-color: hsl(var(--border));
|
border-color: oklch(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: hsl(var(--background));
|
background-color: oklch(var(--background));
|
||||||
color: hsl(var(--foreground));
|
color: oklch(var(--foreground));
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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,
|
html,
|
||||||
@@ -47,18 +47,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.card-shadow {
|
.glass-card {
|
||||||
box-shadow: 0 1px 3px hsl(var(--card-shadow)), 0 1px 2px -1px hsl(var(--card-shadow));
|
@apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shine {
|
.grid-pattern {
|
||||||
background: linear-gradient(
|
background-image:
|
||||||
135deg,
|
linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px),
|
||||||
hsl(var(--primary-soft)) 0%,
|
linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px);
|
||||||
hsl(var(--background)) 50%,
|
background-size: 40px 40px;
|
||||||
hsl(var(--primary-soft)) 100%
|
}
|
||||||
);
|
|
||||||
background-size: 200% 200%;
|
.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 {
|
.animate-bar-pulse {
|
||||||
animation: bar-pulse 0.4s ease-in-out infinite;
|
animation: bar-pulse 0.4s ease-in-out infinite;
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
@@ -88,10 +107,10 @@
|
|||||||
.animate-shimmer {
|
.animate-shimmer {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
hsl(199 30% 90%) 0%,
|
oklch(0.92 0.004 286.32 / 0.5) 0%,
|
||||||
hsl(199 30% 95%) 40%,
|
oklch(0.967 0.001 286.375) 40%,
|
||||||
hsl(199 30% 90%) 80%,
|
oklch(0.92 0.004 286.32 / 0.5) 80%,
|
||||||
hsl(199 30% 85%) 100%
|
oklch(0.92 0.004 286.32 / 0.7) 100%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ export function DashboardLayout({
|
|||||||
}: DashboardLayoutProps) {
|
}: DashboardLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-background text-foreground">
|
<div className="relative min-h-screen bg-background text-foreground">
|
||||||
{/* Sakura particle layer */}
|
{/* Background layers */}
|
||||||
<ParticleBackground />
|
<ParticleBackground />
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 pointer-events-none grid-pattern opacity-[0.03]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative flex min-h-screen">
|
<div className="relative flex min-h-screen">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Badge } from "../shared/ui";
|
|||||||
import type { WsStatus } from "../shared/ws/socket";
|
import type { WsStatus } from "../shared/ws/socket";
|
||||||
|
|
||||||
const titles: Record<DashboardTab, string> = {
|
const titles: Record<DashboardTab, string> = {
|
||||||
live: "Voice, Media & Recordings",
|
live: "Voice & Media",
|
||||||
messages: "Messages & Moderation",
|
messages: "Messages & Moderation",
|
||||||
tuner: "Prompt Tuner",
|
tuner: "Prompt Tuner",
|
||||||
};
|
};
|
||||||
@@ -51,7 +51,7 @@ function WsIndicator({ status }: { status: WsStatus }) {
|
|||||||
/** Voice status indicator dot */
|
/** Voice status indicator dot */
|
||||||
function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
|
function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
|
||||||
const isConnected = voiceStatus.connected;
|
const isConnected = voiceStatus.connected;
|
||||||
const dot = isConnected ? "bg-[#7EC8E3]" : "bg-gray-300";
|
const dot = isConnected ? "bg-primary" : "bg-gray-300";
|
||||||
const label = isConnected
|
const label = isConnected
|
||||||
? voiceStatus.activeChannelName || "connected"
|
? voiceStatus.activeChannelName || "connected"
|
||||||
: "idle";
|
: "idle";
|
||||||
@@ -65,9 +65,9 @@ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
|
|||||||
|
|
||||||
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||||
return (
|
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">
|
<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
|
<motion.div
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
variants={fadeSlideUp}
|
variants={fadeSlideUp}
|
||||||
@@ -75,16 +75,21 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
|||||||
animate="animate"
|
animate="animate"
|
||||||
className="flex items-center gap-3"
|
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">
|
<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>
|
<span className="mx-2 text-muted-foreground">·</span>
|
||||||
{titles[activeTab]}
|
{titles[activeTab]}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground hidden md:block">
|
||||||
{subtitles[activeTab]}
|
{subtitles[activeTab]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Right: status badges */}
|
{/* Right: status badges */}
|
||||||
@@ -92,7 +97,7 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
|||||||
{/* WS Badge */}
|
{/* WS Badge */}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
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" ? (
|
{wsStatus === "connected" ? (
|
||||||
<Wifi className="mr-1.5 h-3 w-3 text-emerald-400" />
|
<Wifi className="mr-1.5 h-3 w-3 text-emerald-400" />
|
||||||
@@ -106,10 +111,8 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
|||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-[#7EC8E3]/20 bg-white/50 px-3 py-1.5 text-xs",
|
"border-border bg-card/50 px-3 py-1.5 text-xs",
|
||||||
voiceStatus.connected
|
voiceStatus.connected ? "text-primary" : "text-muted-foreground",
|
||||||
? "text-[#7EC8E3]"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<VoiceIndicator voiceStatus={voiceStatus} />
|
<VoiceIndicator voiceStatus={voiceStatus} />
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function Sidebar({
|
|||||||
<>
|
<>
|
||||||
<motion.nav
|
<motion.nav
|
||||||
className={cn(
|
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",
|
collapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
layout
|
layout
|
||||||
@@ -60,7 +60,7 @@ export function Sidebar({
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
||||||
alt="GMW"
|
alt="IMPHNEN"
|
||||||
className="h-8 w-8 rounded-xl"
|
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",
|
"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",
|
collapsed ? "justify-center" : "gap-3",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary-soft text-primary ring-2 ring-primary/20"
|
? "bg-primary/10 text-primary ring-1 ring-primary/20"
|
||||||
: "text-muted-foreground hover:bg-primary-soft/40 hover:text-primary/80",
|
: "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" />
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
{!collapsed && <span>{item.label}</span>}
|
{!collapsed && <span>{item.label}</span>}
|
||||||
@@ -114,7 +107,7 @@ export function Sidebar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => mascotChat.setIsOpen(!mascotChat.isOpen)}
|
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"
|
title="Chat dengan mascot"
|
||||||
>
|
>
|
||||||
<MascotImage size="sm" />
|
<MascotImage size="sm" />
|
||||||
@@ -125,7 +118,7 @@ export function Sidebar({
|
|||||||
isOpen={mascotChat.isOpen}
|
isOpen={mascotChat.isOpen}
|
||||||
onClose={() => mascotChat.setIsOpen(false)}
|
onClose={() => mascotChat.setIsOpen(false)}
|
||||||
onSendMessage={mascotChat.handleSendMessage}
|
onSendMessage={mascotChat.handleSendMessage}
|
||||||
mascotName="Discord Watcher"
|
mascotName="IMPHNEN Mascot"
|
||||||
className="fixed bottom-[170px] left-[80px] z-[9999]"
|
className="fixed bottom-[170px] left-[80px] z-[9999]"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function MascotChatbot({
|
|||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
className={cn(
|
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]",
|
isMinimized ? "h-16" : "h-[520px]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -155,7 +155,7 @@ export function MascotChatbot({
|
|||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{!isMinimized && (
|
{!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) => (
|
{messages.map((message) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@@ -177,7 +177,7 @@ export function MascotChatbot({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"max-w-xs px-3 py-2 rounded-xl text-sm break-words",
|
"max-w-xs px-3 py-2 rounded-xl text-sm break-words",
|
||||||
message.role === "user"
|
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",
|
: "bg-muted text-foreground rounded-bl-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -231,7 +231,7 @@ export function MascotChatbot({
|
|||||||
{/* Input */}
|
{/* Input */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSendMessage}
|
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">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -240,14 +240,14 @@ export function MascotChatbot({
|
|||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Tanya mascot..."
|
placeholder="Tanya mascot..."
|
||||||
disabled={loading}
|
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
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !input.trim()}
|
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" />
|
<Send className="h-4 w-4" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function MascotImage({
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Chat bubble */}
|
{/* 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">
|
<div className="flex items-start gap-2">
|
||||||
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-white/80" />
|
<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">
|
<p className="text-xs leading-relaxed font-medium line-clamp-3">
|
||||||
|
|||||||
@@ -1,160 +1,4 @@
|
|||||||
import { Sparkles } from "@react-three/drei";
|
import { useEffect, useState } from "react";
|
||||||
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 ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function ParticleBackground() {
|
export function ParticleBackground() {
|
||||||
const [reducedMotion, setReducedMotion] = useState(false);
|
const [reducedMotion, setReducedMotion] = useState(false);
|
||||||
@@ -171,18 +15,17 @@ export function ParticleBackground() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 pointer-events-none"
|
className="fixed inset-0 pointer-events-none overflow-hidden"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ zIndex: -1, isolation: "isolate" }}
|
style={{ zIndex: -1 }}
|
||||||
>
|
>
|
||||||
<Canvas
|
{/* Top-right glow orb */}
|
||||||
camera={{ position: [0, 0, 8], fov: 75 }}
|
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full bg-primary/10 blur-3xl animate-glow-pulse" />
|
||||||
dpr={[1, 2]}
|
{/* Bottom-left glow orb */}
|
||||||
gl={{ antialias: false, alpha: true }}
|
<div
|
||||||
style={{ background: "transparent", width: "100%", height: "100%" }}
|
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" }}
|
||||||
<ParticleScene />
|
/>
|
||||||
</Canvas>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,24 @@ export default {
|
|||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Poppins", "ui-sans-serif", "system-ui", "-apple-system", "sans-serif"],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "oklch(var(--border))",
|
||||||
input: "hsl(var(--input))",
|
input: "oklch(var(--input))",
|
||||||
ring: "hsl(var(--ring))",
|
ring: "oklch(var(--ring))",
|
||||||
background: "hsl(var(--background))",
|
background: "oklch(var(--background))",
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: "oklch(var(--foreground))",
|
||||||
"primary-soft": "hsl(var(--primary-soft))",
|
"primary-soft": "oklch(var(--primary-soft))",
|
||||||
"primary-glow": "hsl(var(--primary-glow))",
|
"primary-glow": "oklch(var(--primary-glow))",
|
||||||
"accent-glow": "hsl(var(--accent-glow))",
|
"accent-glow": "oklch(var(--accent-glow))",
|
||||||
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
|
primary: { DEFAULT: "oklch(var(--primary))", foreground: "oklch(var(--primary-foreground))" },
|
||||||
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
|
secondary: { DEFAULT: "oklch(var(--secondary))", foreground: "oklch(var(--secondary-foreground))" },
|
||||||
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
|
muted: { DEFAULT: "oklch(var(--muted))", foreground: "oklch(var(--muted-foreground))" },
|
||||||
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
|
accent: { DEFAULT: "oklch(var(--accent))", foreground: "oklch(var(--accent-foreground))" },
|
||||||
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
|
destructive: { DEFAULT: "oklch(var(--destructive))", foreground: "oklch(var(--destructive-foreground))" },
|
||||||
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
|
card: { DEFAULT: "oklch(var(--card))", foreground: "oklch(var(--card-foreground))" },
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
@@ -27,6 +30,9 @@ export default {
|
|||||||
animation: {
|
animation: {
|
||||||
shimmer: "shimmer 2s linear infinite",
|
shimmer: "shimmer 2s linear infinite",
|
||||||
"bar-pulse": "bar-pulse 0.4s ease-in-out 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: {
|
keyframes: {
|
||||||
shimmer: {
|
shimmer: {
|
||||||
@@ -37,6 +43,18 @@ export default {
|
|||||||
"0%, 100%": { transform: "scaleY(0.8)" },
|
"0%, 100%": { transform: "scaleY(0.8)" },
|
||||||
"50%": { transform: "scaleY(1.2)" },
|
"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