feat(ui): implement dynamic view transitions and ambient gradients for themes
This commit is contained in:
1 parent
c7ad4a2da9
commit
31f087b0ba
18 files changed
+1577
-199
No files matched your search
@@ -125,17 +125,17 @@ export default function SummaryPage() {
|
||||
</Link>
|
||||
|
||||
<Link href="/threats" className="block focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-red-500/20 hover:bg-card/100 hover:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute inset-0 bg-red-500/5 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-red-500">
|
||||
<Card className="group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute inset-0 bg-red-500/10 dark:bg-red-500/5 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 p-4 opacity-20 dark:opacity-10 group-hover:opacity-30 dark:group-hover:opacity-20 transition-opacity text-red-600 dark:text-red-500">
|
||||
<ShieldAlert className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-red-400 group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-500 animate-pulse" />
|
||||
<CardTitle className="text-sm font-semibold text-red-700 dark:text-red-400 group-hover:text-red-800 dark:group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-500 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-red-500 tracking-tight">
|
||||
<div className="text-3xl font-bold text-red-600 dark:text-red-500 tracking-tight">
|
||||
{summary.data?.total_threats?.toLocaleString() || "0"}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
+433
-20
@@ -3,12 +3,12 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
@@ -22,7 +22,33 @@
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
|
||||
|
||||
/* OVERRIDE TAILWIND HARDCODED COLORS FOR SEAMLESS LIGHT MODE */
|
||||
--color-slate-900: var(--slate-900);
|
||||
--color-slate-800: var(--slate-800);
|
||||
--color-slate-700: var(--slate-700);
|
||||
--color-slate-600: var(--slate-600);
|
||||
--color-slate-500: var(--slate-500);
|
||||
--color-slate-400: var(--slate-400);
|
||||
--color-slate-300: var(--slate-300);
|
||||
--color-slate-200: var(--slate-200);
|
||||
--color-slate-100: var(--slate-100);
|
||||
--color-slate-50: var(--slate-50);
|
||||
|
||||
--color-gray-900: var(--slate-900);
|
||||
--color-gray-800: var(--slate-800);
|
||||
--color-gray-700: var(--slate-700);
|
||||
--color-gray-600: var(--slate-600);
|
||||
--color-gray-500: var(--slate-500);
|
||||
--color-gray-400: var(--slate-400);
|
||||
--color-gray-300: var(--slate-300);
|
||||
--color-gray-200: var(--slate-200);
|
||||
--color-gray-100: var(--slate-100);
|
||||
--color-gray-50: var(--slate-50);
|
||||
|
||||
--color-white: var(--t-white);
|
||||
--color-black: var(--t-black);
|
||||
|
||||
--font-sans: var(--font-inter);
|
||||
--font-mono: var(--font-inter);
|
||||
|
||||
@@ -31,47 +57,434 @@
|
||||
--animate-slide-up: slide-up 0.5s ease-out forwards;
|
||||
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Premium Dark Theme */
|
||||
--background: #09090b; /* Very dark zinc */
|
||||
--background: #09090b;
|
||||
/* Very dark zinc */
|
||||
--foreground: #fafafa;
|
||||
|
||||
--card: rgba(24, 24, 27, 0.7); /* Zinc 900 with transparency for glassmorphism */
|
||||
|
||||
--card: rgba(24, 24, 27, 0.7);
|
||||
/* Zinc 900 with transparency for glassmorphism */
|
||||
--card-foreground: #fafafa;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--primary: #3b82f6; /* Blue 500 */
|
||||
--primary: #3b82f6;
|
||||
/* Blue 500 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(39, 39, 42, 0.8); /* Zinc 800 */
|
||||
--secondary: rgba(39, 39, 42, 0.8);
|
||||
/* Zinc 800 */
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--muted: rgba(39, 39, 42, 0.5);
|
||||
--muted-foreground: #a1a1aa; /* Zinc 400 */
|
||||
--muted-foreground: #a1a1aa;
|
||||
/* Zinc 400 */
|
||||
|
||||
--destructive: #ef4444; /* Red 500 */
|
||||
--destructive: #ef4444;
|
||||
/* Red 500 */
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
|
||||
--sidebar: #09090b;
|
||||
|
||||
/* Overrides for hardcoded colors in Dark Mode (Normal) */
|
||||
--t-white: #ffffff;
|
||||
--t-black: #000000;
|
||||
--slate-900: #0f172a;
|
||||
--slate-800: #1e293b;
|
||||
--slate-700: #334155;
|
||||
--slate-600: #475569;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #94a3b8;
|
||||
--slate-300: #cbd5e1;
|
||||
--slate-200: #e2e8f0;
|
||||
--slate-100: #f1f5f9;
|
||||
--slate-50: #f8fafc;
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Clean Light Theme */
|
||||
--background: #f1f5f9;
|
||||
/* Slate 100 */
|
||||
--foreground: #0f172a;
|
||||
|
||||
--card: rgba(255, 255, 255, 0.9);
|
||||
--card-foreground: #0f172a;
|
||||
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
|
||||
--primary: #2563eb;
|
||||
/* Blue 600 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(241, 245, 249, 0.8);
|
||||
/* Slate 100 */
|
||||
--secondary-foreground: #0f172a;
|
||||
|
||||
--muted: rgba(226, 232, 240, 0.5);
|
||||
/* Slate 200 */
|
||||
--muted-foreground: #64748b;
|
||||
/* Slate 500 */
|
||||
|
||||
--sidebar: #ffffff;
|
||||
|
||||
/* Overrides for hardcoded colors in Light Mode (Inverted) */
|
||||
--t-white: #0f172a;
|
||||
/* Make text-white become very dark slate */
|
||||
--t-black: #ffffff;
|
||||
--slate-900: #ffffff;
|
||||
--slate-800: #f8fafc;
|
||||
--slate-700: #f1f5f9;
|
||||
--slate-600: #e2e8f0;
|
||||
--slate-500: #cbd5e1;
|
||||
--slate-400: #64748b;
|
||||
/* Keep 400 slightly dark so it stays readable */
|
||||
--slate-300: #475569;
|
||||
--slate-200: #334155;
|
||||
--slate-100: #1e293b;
|
||||
--slate-50: #0f172a;
|
||||
|
||||
/* Enhance contrast for colored text and backgrounds in Light Mode */
|
||||
|
||||
/* RED */
|
||||
--color-red-300: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-400: #dc2626;
|
||||
/* red-600 */
|
||||
--color-red-500: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-950: #ef4444;
|
||||
/* red-500 (so that 950/20 becomes a visible pink bg) */
|
||||
|
||||
/* ORANGE */
|
||||
--color-orange-300: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-400: #ea580c;
|
||||
/* orange-600 */
|
||||
--color-orange-500: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-950: #f97316;
|
||||
/* orange-500 */
|
||||
|
||||
/* YELLOW */
|
||||
--color-yellow-300: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-400: #ca8a04;
|
||||
/* yellow-600 */
|
||||
--color-yellow-500: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-950: #eab308;
|
||||
/* yellow-500 */
|
||||
|
||||
/* GREEN */
|
||||
--color-green-300: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-400: #16a34a;
|
||||
/* green-600 */
|
||||
--color-green-500: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-950: #22c55e;
|
||||
/* green-500 */
|
||||
|
||||
/* EMERALD */
|
||||
--color-emerald-300: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-400: #059669;
|
||||
/* emerald-600 */
|
||||
--color-emerald-500: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-950: #10b981;
|
||||
/* emerald-500 */
|
||||
|
||||
/* BLUE */
|
||||
--color-blue-300: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-400: #2563eb;
|
||||
/* blue-600 */
|
||||
--color-blue-500: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-950: #3b82f6;
|
||||
/* blue-500 */
|
||||
|
||||
/* PURPLE */
|
||||
--color-purple-300: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-400: #9333ea;
|
||||
/* purple-600 */
|
||||
--color-purple-500: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-950: #a855f7;
|
||||
/* purple-500 */
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 0%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* 1-Second Dual-Layer Smooth Background Crossfade */
|
||||
|
||||
:root {
|
||||
/* Premium Dark Theme */
|
||||
--background: #09090b;
|
||||
/* Very dark zinc */
|
||||
--foreground: #fafafa;
|
||||
|
||||
--card: rgba(24, 24, 27, 0.7);
|
||||
/* Zinc 900 with transparency for glassmorphism */
|
||||
--card-foreground: #fafafa;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--primary: #3b82f6;
|
||||
/* Blue 500 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(39, 39, 42, 0.8);
|
||||
/* Zinc 800 */
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--muted: rgba(39, 39, 42, 0.5);
|
||||
--muted-foreground: #a1a1aa;
|
||||
/* Zinc 400 */
|
||||
|
||||
--destructive: #ef4444;
|
||||
/* Red 500 */
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--sidebar: #09090b;
|
||||
|
||||
/* Overrides for hardcoded colors in Dark Mode (Normal) */
|
||||
--t-white: #ffffff;
|
||||
--t-black: #000000;
|
||||
--slate-900: #0f172a;
|
||||
--slate-800: #1e293b;
|
||||
--slate-700: #334155;
|
||||
--slate-600: #475569;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #94a3b8;
|
||||
--slate-300: #cbd5e1;
|
||||
--slate-200: #e2e8f0;
|
||||
--slate-100: #f1f5f9;
|
||||
--slate-50: #f8fafc;
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Clean Light Theme */
|
||||
--background: #f1f5f9;
|
||||
/* Slate 100 */
|
||||
--foreground: #0f172a;
|
||||
|
||||
--card: rgba(255, 255, 255, 0.9);
|
||||
--card-foreground: #0f172a;
|
||||
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
|
||||
--primary: #2563eb;
|
||||
/* Blue 600 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(241, 245, 249, 0.8);
|
||||
/* Slate 100 */
|
||||
--secondary-foreground: #0f172a;
|
||||
|
||||
--muted: rgba(226, 232, 240, 0.5);
|
||||
/* Slate 200 */
|
||||
--muted-foreground: #64748b;
|
||||
/* Slate 500 */
|
||||
|
||||
--sidebar: #ffffff;
|
||||
|
||||
/* Overrides for hardcoded colors in Light Mode (Inverted) */
|
||||
--t-white: #0f172a;
|
||||
/* Make text-white become very dark slate */
|
||||
--t-black: #ffffff;
|
||||
--slate-900: #ffffff;
|
||||
--slate-800: #f8fafc;
|
||||
--slate-700: #f1f5f9;
|
||||
--slate-600: #e2e8f0;
|
||||
--slate-500: #cbd5e1;
|
||||
--slate-400: #64748b;
|
||||
/* Keep 400 slightly dark so it stays readable */
|
||||
--slate-300: #475569;
|
||||
--slate-200: #334155;
|
||||
--slate-100: #1e293b;
|
||||
--slate-50: #0f172a;
|
||||
|
||||
/* Enhance contrast for colored text and backgrounds in Light Mode */
|
||||
|
||||
/* RED */
|
||||
--color-red-300: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-400: #dc2626;
|
||||
/* red-600 */
|
||||
--color-red-500: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-950: #ef4444;
|
||||
/* red-500 (so that 950/20 becomes a visible pink bg) */
|
||||
|
||||
/* ORANGE */
|
||||
--color-orange-300: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-400: #ea580c;
|
||||
/* orange-600 */
|
||||
--color-orange-500: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-950: #f97316;
|
||||
/* orange-500 */
|
||||
|
||||
/* YELLOW */
|
||||
--color-yellow-300: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-400: #ca8a04;
|
||||
/* yellow-600 */
|
||||
--color-yellow-500: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-950: #eab308;
|
||||
/* yellow-500 */
|
||||
|
||||
/* GREEN */
|
||||
--color-green-300: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-400: #16a34a;
|
||||
/* green-600 */
|
||||
--color-green-500: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-950: #22c55e;
|
||||
/* green-500 */
|
||||
|
||||
/* EMERALD */
|
||||
--color-emerald-300: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-400: #059669;
|
||||
/* emerald-600 */
|
||||
--color-emerald-500: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-950: #10b981;
|
||||
/* emerald-500 */
|
||||
|
||||
/* BLUE */
|
||||
--color-blue-300: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-400: #2563eb;
|
||||
/* blue-600 */
|
||||
--color-blue-500: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-950: #3b82f6;
|
||||
/* blue-500 */
|
||||
|
||||
/* PURPLE */
|
||||
--color-purple-300: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-400: #9333ea;
|
||||
/* purple-600 */
|
||||
--color-purple-500: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-950: #a855f7;
|
||||
/* purple-500 */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* 1-Second Dual-Layer Smooth Background Crossfade */
|
||||
body::before,
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
transition: opacity 500ms ease-in-out;
|
||||
pointer-events: none;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Dark Mode Background Layer (Active by default, fades out on .light) */
|
||||
body::before {
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
radial-gradient(circle at 0% 0%, rgba(30, 58, 138, 0.35) 0%, transparent 40%),
|
||||
radial-gradient(circle at 100% 20%, rgba(220, 38, 38, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(circle at 50% 100%, rgba(15, 23, 42, 0.8) 0%, transparent 60%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.light body::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Light Mode Background Layer (Hidden by default, fades in on .light) */
|
||||
body::after {
|
||||
background-color: #f1f5f9;
|
||||
background-image:
|
||||
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.1) 0px, transparent 40%),
|
||||
radial-gradient(circle at 100% 0%, rgba(239, 68, 68, 0.05) 0px, transparent 40%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.light body::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ambient Gradients for Cards & Sidebars */
|
||||
:root {
|
||||
--ambient-card-bg: linear-gradient(135deg, rgba(30, 58, 138, 0.1) 0%, rgba(15, 23, 42, 0.5) 100%);
|
||||
--ambient-sidebar-bg: linear-gradient(180deg, rgba(15, 23, 42, 0.85) 0%, rgba(2, 6, 23, 0.95) 100%);
|
||||
}
|
||||
|
||||
.light {
|
||||
--ambient-card-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(241, 245, 249, 0.65) 100%);
|
||||
--ambient-sidebar-bg: linear-gradient(180deg, rgba(255, 255, 255, 1) 0%, rgba(241, 245, 249, 0.9) 100%);
|
||||
}
|
||||
|
||||
.bg-card {
|
||||
background-image: var(--ambient-card-bg) !important;
|
||||
background-color: transparent !important;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.bg-sidebar {
|
||||
background-image: var(--ambient-sidebar-bg) !important;
|
||||
background-color: transparent !important;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Global smooth transition for theme change */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, background-image;
|
||||
transition-timing-function: ease-in-out;
|
||||
transition-duration: 500ms;
|
||||
}
|
||||
/* View Transitions API (For perfect whole-screen crossfade including WebGL/Canvas) */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 500ms;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
+8
-1
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import "./globals.css";
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -23,7 +24,13 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
{children}
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem={false}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+11
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lock, User } from "lucide-react";
|
||||
import { Lock, User, Eye, EyeOff } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -10,6 +10,7 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
@@ -80,13 +81,20 @@ export default function LoginPage() {
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
className="w-full pl-10 pr-10 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-white transition-colors focus:outline-none"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<div className="flex h-screen overflow-hidden bg-transparent transition-colors duration-500">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header
|
||||
@@ -62,7 +62,7 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
onRefresh={handleRefresh}
|
||||
intervalText={intervalText}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto bg-[#0d1117] p-6">
|
||||
<main className="flex-1 overflow-y-auto bg-transparent p-6 transition-colors duration-500">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,28 @@ interface AppNotification {
|
||||
type: 'threat' | 'event';
|
||||
}
|
||||
|
||||
function RealtimeClock() {
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(new Date());
|
||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!time) return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground font-mono shadow-sm opacity-0">
|
||||
00:00:00
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs font-mono shadow-sm text-cyan-400">
|
||||
{time.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({
|
||||
lastUpdate = "—",
|
||||
isRefreshing = false,
|
||||
@@ -171,13 +193,13 @@ export function Header({
|
||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Site: {process.env.NEXT_PUBLIC_SITE_ID || "Unknown Site"}
|
||||
</div>
|
||||
|
||||
{intervalText && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 px-3 py-1 text-xs text-blue-400 font-medium">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>Rentang Data: {intervalText}</span>
|
||||
</div>
|
||||
)}
|
||||
<RealtimeClock />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
+622
-119
@@ -3,21 +3,30 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Activity,
|
||||
AppWindow,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Server,
|
||||
Zap,
|
||||
Activity,
|
||||
Globe,
|
||||
Radio,
|
||||
Zap,
|
||||
ShieldAlert,
|
||||
Shield,
|
||||
LogOut,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AppWindow,
|
||||
Search,
|
||||
LogOut
|
||||
User,
|
||||
Image as ImageIcon,
|
||||
Upload,
|
||||
Trash2,
|
||||
Sun,
|
||||
Moon
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
const navigation = [
|
||||
@@ -41,10 +50,19 @@ const navigation = [
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Change Password state
|
||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string } | null>(null);
|
||||
|
||||
const [isAccountOpen, setIsAccountOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'account_name' | 'username' | 'password' | 'profile_picture'>('account_name');
|
||||
|
||||
// Password state
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
@@ -52,14 +70,36 @@ export function Sidebar() {
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Username state
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [usernameErrorMsg, setUsernameErrorMsg] = useState("");
|
||||
const [usernameSuccessMsg, setUsernameSuccessMsg] = useState("");
|
||||
const [isSubmittingUsername, setIsSubmittingUsername] = useState(false);
|
||||
|
||||
// Account Name state
|
||||
const [newAccountName, setNewAccountName] = useState("");
|
||||
const [accountNameErrorMsg, setAccountNameErrorMsg] = useState("");
|
||||
const [accountNameSuccessMsg, setAccountNameSuccessMsg] = useState("");
|
||||
const [isSubmittingAccountName, setIsSubmittingAccountName] = useState(false);
|
||||
|
||||
// Profile Picture state
|
||||
const [profilePicFile, setProfilePicFile] = useState<File | null>(null);
|
||||
const [profilePicErrorMsg, setProfilePicErrorMsg] = useState("");
|
||||
const [profilePicSuccessMsg, setProfilePicSuccessMsg] = useState("");
|
||||
const [isUploadingPic, setIsUploadingPic] = useState(false);
|
||||
|
||||
// Change Password visibility state
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Real-time checks for password strength validation
|
||||
const isMinLength = newPassword.length >= 8;
|
||||
const isMinLength = newPassword.length >= 6;
|
||||
const hasUppercase = /[A-Z]/.test(newPassword);
|
||||
const hasLowercase = /[a-z]/.test(newPassword);
|
||||
const hasNumber = /[0-9]/.test(newPassword);
|
||||
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(newPassword);
|
||||
const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0;
|
||||
const isPasswordValid = isMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecial && passwordsMatch;
|
||||
const isPasswordValid = isMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
@@ -85,6 +125,7 @@ export function Sidebar() {
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isPasswordValid) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
@@ -95,26 +136,143 @@ export function Sidebar() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Password berhasil diubah!");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTimeout(() => {
|
||||
setIsChangePasswordOpen(false);
|
||||
setSuccessMsg("");
|
||||
}, 1500);
|
||||
} else {
|
||||
setErrorMsg(data.error || "Gagal mengubah password");
|
||||
setErrorMsg(data.error || "Gagal mengubah password.");
|
||||
}
|
||||
} catch (err) {
|
||||
setErrorMsg("Terjadi kesalahan koneksi");
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeUsername = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmittingUsername(true);
|
||||
setUsernameErrorMsg("");
|
||||
setUsernameSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-username', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newUsername }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setUsernameSuccessMsg("Username berhasil diubah!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, username: data.newUsername });
|
||||
}
|
||||
} else {
|
||||
setUsernameErrorMsg(data.error || "Gagal mengubah username.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setUsernameErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmittingUsername(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeAccountName = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmittingAccountName(true);
|
||||
setAccountNameErrorMsg("");
|
||||
setAccountNameSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-account-name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newAccountName }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setAccountNameSuccessMsg("Nama akun berhasil diubah!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, account_name: data.newAccountName });
|
||||
}
|
||||
} else {
|
||||
setAccountNameErrorMsg(data.error || "Gagal mengubah nama akun.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAccountNameErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmittingAccountName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadProfilePicture = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profilePicFile) return;
|
||||
|
||||
setIsUploadingPic(true);
|
||||
setProfilePicErrorMsg("");
|
||||
setProfilePicSuccessMsg("");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', profilePicFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProfilePicSuccessMsg("Foto profil berhasil diperbarui!");
|
||||
setProfilePicFile(null);
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: data.profile_picture });
|
||||
}
|
||||
} else {
|
||||
setProfilePicErrorMsg(data.error || "Gagal mengunggah foto profil.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfilePicErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsUploadingPic(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProfilePicture = async () => {
|
||||
setIsUploadingPic(true);
|
||||
setProfilePicErrorMsg("");
|
||||
setProfilePicSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/remove-profile-picture', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProfilePicSuccessMsg("Foto profil berhasil dihapus!");
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: undefined });
|
||||
}
|
||||
} else {
|
||||
setProfilePicErrorMsg(data.error || "Gagal menghapus foto profil.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfilePicErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsUploadingPic(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredNavigation = navigation.filter((item) => {
|
||||
if (user?.role === 'AGENT_VIEWER' && item.name === 'Agents') {
|
||||
return false;
|
||||
@@ -138,7 +296,33 @@ export function Sidebar() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto relative z-10">
|
||||
|
||||
<div className="py-6 flex flex-col items-center border-b border-border/50 relative z-10">
|
||||
<div className="relative">
|
||||
<div className="h-16 w-16 rounded-full bg-gradient-to-tr from-primary to-blue-400 p-0.5 shadow-lg shadow-primary/20 overflow-hidden">
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-background overflow-hidden relative">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xl font-bold text-white uppercase">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 rounded-full border-2 border-background bg-green-500 shadow-sm" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col items-center w-full px-4 space-y-1.5">
|
||||
<span className="font-semibold text-white tracking-wide text-center leading-snug break-words max-w-full">
|
||||
{user?.account_name || user?.username || 'Loading...'}
|
||||
</span>
|
||||
<span className="rounded-full bg-primary/20 px-2.5 py-0.5 text-[10px] font-medium text-primary border border-primary/30 shrink-0">
|
||||
{user?.role === 'SUPER_ADMIN' ? 'PRO' : 'STD'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto relative z-10 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/10 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-white/20 transition-colors">
|
||||
<nav className="flex-1 space-y-1.5 px-3 py-6">
|
||||
{filteredNavigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
@@ -179,123 +363,442 @@ export function Sidebar() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border/50 relative z-10 shrink-0 space-y-1.5">
|
||||
<div className="px-3 py-4 border-t border-border/50 relative z-10 shrink-0 space-y-1.5 flex flex-col">
|
||||
<button
|
||||
onClick={() => {
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
setUsernameErrorMsg("");
|
||||
setUsernameSuccessMsg("");
|
||||
setAccountNameErrorMsg("");
|
||||
setAccountNameSuccessMsg("");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setIsChangePasswordOpen(true);
|
||||
setNewUsername("");
|
||||
setNewAccountName("");
|
||||
setProfilePicFile(null);
|
||||
setActiveTab("account_name");
|
||||
setIsAccountOpen(true);
|
||||
}}
|
||||
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-slate-300 hover:bg-secondary/50 hover:text-white transition-all duration-200"
|
||||
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-slate-400 hover:bg-secondary/50 hover:text-white transition-all duration-200"
|
||||
>
|
||||
<Lock className="mr-3 h-5 w-5" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-all duration-200"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5" />
|
||||
Logout
|
||||
<User className="mr-3 h-5 w-5" />
|
||||
Account Settings
|
||||
</button>
|
||||
<div className="flex w-full space-x-1.5">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex flex-1 items-center rounded-lg px-3 py-2.5 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-all duration-200"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5" />
|
||||
Logout
|
||||
</button>
|
||||
|
||||
{mounted && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
if (!document.startViewTransition) {
|
||||
setTheme(nextTheme);
|
||||
return;
|
||||
}
|
||||
document.startViewTransition(() => {
|
||||
setTheme(nextTheme);
|
||||
});
|
||||
}}
|
||||
className="flex items-center justify-center rounded-lg px-3 py-2.5 text-slate-400 hover:bg-secondary/50 hover:text-white transition-all duration-200"
|
||||
title="Toggle Theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={isChangePasswordOpen} onClose={() => setIsChangePasswordOpen(false)} title="Ubah Password">
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
<Modal isOpen={isAccountOpen} onClose={() => setIsAccountOpen(false)} title="Account Settings">
|
||||
<div className="flex mb-6 border-b border-border/50">
|
||||
<button
|
||||
onClick={() => setActiveTab('account_name')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'account_name' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Nama
|
||||
{activeTab === 'account_name' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('profile_picture')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'profile_picture' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Foto Profil
|
||||
{activeTab === 'profile_picture' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('username')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'username' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Username
|
||||
{activeTab === 'username' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'password' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Password
|
||||
{activeTab === 'password' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'profile_picture' ? (
|
||||
<div className="space-y-4">
|
||||
{profilePicErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{profilePicErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{profilePicSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{profilePicSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="h-24 w-24 rounded-full bg-gradient-to-tr from-primary to-blue-400 p-0.5 shadow-lg shadow-primary/20 mb-4 overflow-hidden relative group">
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-background overflow-hidden relative">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-white uppercase">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 w-full">
|
||||
<form onSubmit={handleUploadProfilePicture} className="flex-1 flex flex-col space-y-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setProfilePicFile(e.target.files ? e.target.files[0] : null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="flex items-center justify-center w-full px-4 py-2 border border-dashed border-border rounded-lg bg-secondary/30 hover:bg-secondary/50 transition-colors">
|
||||
<Upload className="w-4 h-4 mr-2 text-slate-400" />
|
||||
<span className="text-sm text-slate-300 truncate">
|
||||
{profilePicFile ? profilePicFile.name : "Pilih File Gambar"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profilePicFile || isUploadingPic}
|
||||
className="w-full rounded-lg px-4 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center justify-center"
|
||||
>
|
||||
{isUploadingPic ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isUploadingPic ? "Mengunggah..." : "Unggah Foto"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{user?.profile_picture && (
|
||||
<button
|
||||
onClick={handleRemoveProfilePicture}
|
||||
disabled={isUploadingPic}
|
||||
className="flex flex-col items-center justify-center px-4 py-2 border border-red-500/30 rounded-lg text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 mb-1" />
|
||||
<span className="text-[10px] font-medium">Hapus</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAccountOpen(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === 'account_name' ? (
|
||||
<form onSubmit={handleChangeAccountName} className="space-y-4">
|
||||
{accountNameErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{accountNameErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{accountNameSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{accountNameSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Baru</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Nama Tampilan Baru</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAccountName}
|
||||
onChange={(e) => setNewAccountName(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="Misal: Super Admin Budi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Konfirmasi Password Baru</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini (Otorisasi)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Ketik password untuk melanjutkan"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Checklist */}
|
||||
<div className="rounded-lg bg-secondary/30 p-3.5 border border-border/50 space-y-2">
|
||||
<span className="text-xs font-semibold text-slate-300">Syarat Kekuatan Password:</span>
|
||||
<ul className="text-xs space-y-1.5">
|
||||
<li className={cn("flex items-center transition-colors duration-200", isMinLength ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2">{isMinLength ? "✓" : "○"}</span> Minimal 8 karakter
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasUppercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2">{hasUppercase ? "✓" : "○"}</span> Mengandung huruf besar (A-Z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasLowercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2">{hasLowercase ? "✓" : "○"}</span> Mengandung huruf kecil (a-z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasNumber ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2">{hasNumber ? "✓" : "○"}</span> Mengandung angka (0-9)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasSpecial ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2">{hasSpecial ? "✓" : "○"}</span> Mengandung karakter spesial (contoh: @$!%*?&)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", passwordsMatch ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2">{passwordsMatch ? "✓" : "○"}</span> Password cocok
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAccountOpen(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newAccountName || !currentPassword || isSubmittingAccountName}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmittingAccountName ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmittingAccountName ? "Menyimpan..." : "Simpan Nama Akun"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : activeTab === 'username' ? (
|
||||
<form onSubmit={handleChangeUsername} className="space-y-4">
|
||||
{usernameErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{usernameErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{usernameSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{usernameSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsChangePasswordOpen(false)}
|
||||
className="rounded-lg bg-secondary px-4 py-2 text-sm font-medium text-white hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isPasswordValid || isSubmitting}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg transition-all duration-200",
|
||||
isPasswordValid && !isSubmitting
|
||||
? "bg-primary hover:bg-primary/90 shadow-primary/25 cursor-pointer"
|
||||
: "bg-slate-700 text-slate-400 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? "Menyimpan..." : "Simpan Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Username Baru</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="Masukkan username baru (min. 4 karakter)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini (Otorisasi)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Ketik password untuk melanjutkan"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAccountOpen(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newUsername || !currentPassword || isSubmittingUsername}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmittingUsername ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmittingUsername ? "Menyimpan..." : "Simpan Username"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Masukkan password saat ini"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Baru</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Ketik password baru"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showNew ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Konfirmasi Password Baru</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Ketik ulang password baru"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showConfirm ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Checklist */}
|
||||
<div className="rounded-xl bg-gradient-to-br from-secondary/40 to-background p-4 border border-border/50 shadow-inner space-y-3">
|
||||
<span className="text-xs font-semibold text-slate-200">Syarat Kekuatan Password:</span>
|
||||
<ul className="text-xs space-y-2">
|
||||
<li className={cn("flex items-center transition-colors duration-200", isMinLength ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{isMinLength ? "✓" : "○"}</span> Minimal 6 karakter
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasUppercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasUppercase ? "✓" : "○"}</span> Mengandung huruf besar (A-Z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasLowercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasLowercase ? "✓" : "○"}</span> Mengandung huruf kecil (a-z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasNumber ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasNumber ? "✓" : "○"}</span> Mengandung angka (0-9)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", passwordsMatch ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{passwordsMatch ? "✓" : "○"}</span> Password cocok
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAccountOpen(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isPasswordValid || isSubmitting}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Menyimpan..." : "Simpan Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ export function DataTable<T>({
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th key={i} className={`px-4 py-3.5 font-medium whitespace-nowrap tracking-wide ${col.className || ""}`}>
|
||||
<th key={i} className={`px-4 py-3.5 font-medium tracking-wide ${col.className || ""}`}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
@@ -91,7 +91,7 @@ export function DataTable<T>({
|
||||
className={`hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-4 py-3 whitespace-nowrap text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
<td key={j} className={`px-4 py-3 text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
</td>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { fmtBytes } from '@/lib/utils';
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// Dynamically import Globe to avoid SSR issues
|
||||
const Globe = dynamic(() => import('react-globe.gl'), { ssr: false });
|
||||
@@ -40,7 +41,46 @@ interface GlobeMapProps {
|
||||
export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
const globeRef = useRef<any>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 500 });
|
||||
const [countries, setCountries] = useState<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
// Generate a beautiful, ultra-fresh gradient blue texture for the Light Mode ocean
|
||||
const lightGlobeTexture = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1024;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 512);
|
||||
gradient.addColorStop(0, '#bae6fd'); // sky-200 (light bright blue at the top)
|
||||
gradient.addColorStop(0.5, '#38bdf8'); // sky-400 (vibrant blue equator)
|
||||
gradient.addColorStop(1, '#0284c7'); // sky-600 (deeper rich blue at the bottom)
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 1024, 512);
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}, []);
|
||||
|
||||
// Generate a sleek, deep gradient dark texture for the Dark Mode ocean
|
||||
const darkGlobeTexture = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1024;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 512);
|
||||
gradient.addColorStop(0, '#0f172a'); // slate-900
|
||||
gradient.addColorStop(0.5, '#020617'); // slate-950 (deepest dark blue/black)
|
||||
gradient.addColorStop(1, '#000000'); // black
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 1024, 512);
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle resizing
|
||||
@@ -52,21 +92,47 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
updateDimensions();
|
||||
window.addEventListener('resize', updateDimensions);
|
||||
|
||||
// Fetch high-res vector polygons for both themes' continents
|
||||
fetch('https://unpkg.com/globe.gl/example/datasets/ne_110m_admin_0_countries.geojson')
|
||||
.then(res => res.json())
|
||||
.then(setCountries)
|
||||
.catch(err => console.error('Failed to load countries', err));
|
||||
|
||||
return () => window.removeEventListener('resize', updateDimensions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-rotate configuration
|
||||
if (globeRef.current) {
|
||||
globeRef.current.controls().autoRotate = true;
|
||||
globeRef.current.controls().autoRotateSpeed = 1.0;
|
||||
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: ORIGIN_LAT, lng: ORIGIN_LNG, altitude: 1.8 }, 2000);
|
||||
}
|
||||
// Auto-rotate and controls configuration
|
||||
// Since Globe is dynamically imported, we must wait until the ref is actually populated.
|
||||
let checkInterval = setInterval(() => {
|
||||
if (globeRef.current && globeRef.current.controls) {
|
||||
clearInterval(checkInterval);
|
||||
|
||||
const controls = globeRef.current.controls();
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 1.2; // Adjusted auto-rotate speed to 1.5
|
||||
|
||||
// Enable inertia/damping for smooth deceleration after dragging
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.035;
|
||||
|
||||
// Adjust the manual rotation sensitivity
|
||||
controls.rotateSpeed = 0.8;
|
||||
|
||||
// Remove boundaries to allow 360 degree rotation in all directions (as much as OrbitControls allows)
|
||||
controls.minPolarAngle = 0; // The top pole (North)
|
||||
controls.maxPolarAngle = Math.PI; // The bottom pole (South)
|
||||
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: ORIGIN_LAT, lng: ORIGIN_LNG, altitude: 1.8 }, 2000);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, []);
|
||||
|
||||
const arcsData = useMemo(() => {
|
||||
@@ -95,7 +161,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
name: arc.name,
|
||||
value: arc.value
|
||||
}));
|
||||
|
||||
|
||||
// Add origin point
|
||||
points.push({
|
||||
lat: ORIGIN_LAT,
|
||||
@@ -105,30 +171,34 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
name: 'Indonesia (Origin)',
|
||||
value: 0
|
||||
});
|
||||
|
||||
|
||||
return points;
|
||||
}, [arcsData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-[500px] rounded-xl overflow-visible bg-transparent cursor-move flex items-center justify-center"
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-[500px] rounded-xl overflow-visible bg-transparent cursor-move flex items-center justify-center transition-all duration-500"
|
||||
>
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-black/60 p-3 rounded-lg border border-white/10 backdrop-blur-md">
|
||||
<div className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Global Traffic Flows
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Interactive 3D visualization of network traffic</p>
|
||||
</div>
|
||||
|
||||
<Globe
|
||||
ref={globeRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
|
||||
bumpImageUrl="//unpkg.com/three-globe/example/img/earth-topology.png"
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
showGraticules={true}
|
||||
globeImageUrl={isLight ? lightGlobeTexture : darkGlobeTexture}
|
||||
showAtmosphere={true}
|
||||
atmosphereColor={isLight ? "#0ea5e9" : "#1e40af"}
|
||||
atmosphereAltitude={isLight ? 0.15 : 0.25}
|
||||
|
||||
// Polygons Configuration for Unified Vector Designs
|
||||
polygonsData={countries ? countries.features : []}
|
||||
polygonAltitude={0.015}
|
||||
polygonCapColor={() => isLight ? '#4ade80' : '#0f172a'} // Fresh green for light, dark slate for dark
|
||||
polygonSideColor={() => isLight ? '#22c55e' : '#020617'} // Depth colors
|
||||
polygonStrokeColor={() => isLight ? '#bbf7d0' : '#0284c7'} // Crisp bright borders for light, neon blue for dark
|
||||
|
||||
// Arcs configuration
|
||||
arcsData={arcsData}
|
||||
arcStartLat={(d: any) => d.startLat}
|
||||
@@ -141,7 +211,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
arcDashAnimateTime={2000}
|
||||
arcAltitude={0.3}
|
||||
arcStroke={0.7}
|
||||
|
||||
|
||||
// Points configuration
|
||||
pointsData={pointsData}
|
||||
pointLat="lat"
|
||||
@@ -150,7 +220,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
pointAltitude={0.01}
|
||||
pointRadius="size"
|
||||
pointsMerge={true}
|
||||
|
||||
|
||||
// Rings configuration (Ping effect for points)
|
||||
ringsData={pointsData}
|
||||
ringLat="lat"
|
||||
@@ -159,7 +229,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
ringMaxRadius={(d: any) => d.name === 'Indonesia (Origin)' ? 3 : 1.5}
|
||||
ringPropagationSpeed={3}
|
||||
ringRepeatPeriod={1000}
|
||||
|
||||
|
||||
// Labels configuration
|
||||
labelsData={pointsData}
|
||||
labelLat="lat"
|
||||
@@ -167,7 +237,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
labelText="name"
|
||||
labelSize={1.2}
|
||||
labelDotRadius={0.5}
|
||||
labelColor={() => 'rgba(255, 255, 255, 0.8)'}
|
||||
labelColor={() => isLight ? '#0f172a' : 'rgba(255, 255, 255, 0.8)'}
|
||||
labelResolution={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -29,7 +30,7 @@ export function Modal({ isOpen, onClose, title, children, className }: ModalProp
|
||||
|
||||
if (!isMounted || !isOpen) return null;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
@@ -56,6 +57,7 @@ export function Modal({ isOpen, onClose, title, children, className }: ModalProp
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -11,16 +11,11 @@ import {
|
||||
} from "react-simple-maps";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// The topojson file containing the world shapes
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
// Our custom heatmap color scale
|
||||
// Maps values from 0 to maxValue to a gradient (Dark Blue to Electric Cyan/Purple)
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain([0, 100]) // We will update the domain dynamically based on data
|
||||
.range(["#1e293b", "#38bdf8"]); // Tailwind slate-800 to sky-400
|
||||
|
||||
interface WorldMapProps {
|
||||
data: {
|
||||
countryCode: string; // ISO A2 code
|
||||
@@ -38,14 +33,27 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
const max = Math.max(...data.map(d => d.value));
|
||||
setMaxValue(max);
|
||||
colorScale.domain([0, max]);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain(maxValue > 0 ? [0, maxValue * 0.33, maxValue * 0.66, maxValue] : [0, 1, 2, 3])
|
||||
.range(isLight
|
||||
? ["#bbf7d0", "#fef08a", "#fb923c", "#ef4444"] // Light: Green -> Yellow -> Orange -> Red
|
||||
: ["#1e293b", "#0f172a", "#0284c7", "#38bdf8"] // Dark: Slate -> Sky
|
||||
);
|
||||
|
||||
const defaultLandColor = isLight ? "#86efac" : "#1e293b";
|
||||
const oceanColor = isLight ? "#bfdbfe" : "transparent";
|
||||
const strokeColor = isLight ? "rgba(0,0,0,0.15)" : "rgba(255,255,255,0.1)";
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
@@ -56,7 +64,14 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="text-xs text-muted-foreground">Low</div>
|
||||
<div className="w-24 h-2 rounded-full bg-gradient-to-r from-slate-800 to-sky-400 shadow-sm"></div>
|
||||
<div
|
||||
className="w-24 h-2 rounded-full shadow-sm"
|
||||
style={{
|
||||
background: isLight
|
||||
? 'linear-gradient(to right, #bbf7d0, #fef08a, #fb923c, #ef4444)'
|
||||
: 'linear-gradient(to right, #1e293b, #38bdf8)'
|
||||
}}
|
||||
></div>
|
||||
<div className="text-xs text-muted-foreground">High</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,8 +95,8 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
className="w-full h-full"
|
||||
>
|
||||
<ZoomableGroup zoom={1} maxZoom={4} translateExtent={[[-100, -100], [900, 500]]}>
|
||||
<Sphere stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} id="sphere" fill="transparent" />
|
||||
<Graticule stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
|
||||
<Sphere stroke={strokeColor} strokeWidth={0.5} id="sphere" fill={oceanColor} />
|
||||
<Graticule stroke={strokeColor} strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
@@ -99,14 +114,14 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
(sLabel === 'south korea' && geoName === 'korea');
|
||||
});
|
||||
|
||||
const color = d ? colorScale(d.value) : "#1e293b"; // Default color
|
||||
const color = d ? colorScale(d.value) : defaultLandColor;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={color}
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
|
||||
Reference in new issue
Block a user