feat(ui): implement dynamic view transitions and ambient gradients for themes

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-01 17:48:38 +07:00
1 parent c7ad4a2da9
commit 31f087b0ba
18 files changed
+1577 -199

No files matched your search

+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+8
View File
@@ -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>;
}
+2 -2
View File
@@ -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>
+23 -1
View File
@@ -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
View File
@@ -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>
);
+2 -2
View File
@@ -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>
))}
+96 -26
View File
@@ -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>
+4 -2
View File
@@ -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
);
}
+27 -12
View File
@@ -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" },