feat: inisialisasi project kit v2
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { Topbar } from './components/Topbar';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AuthenticatedLayout({ children }: Props) {
|
||||
const { system_settings } = usePage().props as any;
|
||||
const primaryColor = system_settings?.primary_color;
|
||||
|
||||
const [theme, setTheme] = React.useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('theme') || 'light';
|
||||
}
|
||||
return 'light';
|
||||
});
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// Close sidebar on route change (mobile)
|
||||
const url = usePage().url;
|
||||
React.useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden transition-colors duration-300 bg-[#E3EBE8] dark:bg-[#1A2120]"
|
||||
style={primaryColor ? {
|
||||
'--color-primary-500': primaryColor,
|
||||
'--color-accent-gold': primaryColor
|
||||
} as React.CSSProperties : {}}>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
<div
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`fixed inset-0 z-10 bg-black/40 backdrop-blur-sm lg:hidden transition-opacity duration-300
|
||||
${sidebarOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
|
||||
/>
|
||||
|
||||
{/* Sidebar — desktop: always visible in flow; mobile: fixed slide-in */}
|
||||
<div className="hidden lg:block shrink-0">
|
||||
<Sidebar theme={theme} />
|
||||
</div>
|
||||
<div className={`fixed inset-y-0 left-0 z-20 lg:hidden transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<Sidebar theme={theme} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 h-full relative">
|
||||
<Topbar
|
||||
theme={theme}
|
||||
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
||||
onMenuToggle={() => setSidebarOpen(o => !o)}
|
||||
sidebarOpen={sidebarOpen}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-6 pt-6 pb-6 custom-scrollbar">
|
||||
<div key={url} className="max-w-[1600px] mx-auto anim-page">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
||||
export default function GuestLayout({ children }: { children: React.ReactNode }) {
|
||||
const props = usePage().props as any;
|
||||
const { system_settings } = props;
|
||||
const appName = system_settings?.app_name || 'biiproject kit v2';
|
||||
const appLogo = system_settings?.app_logo;
|
||||
const appLogoText = system_settings?.app_logo_text || 'BK';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex font-sans">
|
||||
|
||||
{/* ── Left brand panel ─────────────────────────────────────────── */}
|
||||
<div className="hidden lg:flex lg:w-[44%] bg-[#3D4E4B] flex-col justify-between p-14 relative overflow-hidden shrink-0">
|
||||
|
||||
{/* Dot-grid texture */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="dots" x="0" y="0" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1.5" cy="1.5" r="1.5" fill="white" fillOpacity="0.07" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#dots)" />
|
||||
</svg>
|
||||
|
||||
{/* Decorative rings */}
|
||||
<div className="absolute -bottom-40 -right-40 w-[480px] h-[480px] rounded-full border border-white/[0.06] pointer-events-none" />
|
||||
<div className="absolute -bottom-64 -right-64 w-[700px] h-[700px] rounded-full border border-white/[0.04] pointer-events-none" />
|
||||
<div className="absolute top-[-60px] left-[-60px] w-[300px] h-[300px] rounded-full border border-white/[0.04] pointer-events-none" />
|
||||
|
||||
{/* Logo */}
|
||||
<div className="relative z-10 anim-fade">
|
||||
<Link href="/" className="inline-flex items-center gap-3 group">
|
||||
<div className={`w-10 h-10 rounded-[0.75rem] flex items-center justify-center overflow-hidden text-sm font-bold text-white shrink-0 ${!appLogo ? 'bg-[#D4A017]' : 'bg-white/10'}`}>
|
||||
{appLogo
|
||||
? <img src={appLogo} alt={appName} className="w-full h-full object-contain" />
|
||||
: appLogoText
|
||||
}
|
||||
</div>
|
||||
<span className="text-white font-bold text-base tracking-tight">{appName}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Main copy */}
|
||||
<div className="relative z-10 anim-up" style={{ animationDelay: '0.1s' }}>
|
||||
<p className="text-[#D4A017] text-xs font-bold uppercase tracking-[0.18em] mb-5">Enterprise Platform</p>
|
||||
<h2 className="text-white text-[2rem] font-bold leading-[1.2] tracking-tight">
|
||||
Manage your<br />organization<br />with precision.
|
||||
</h2>
|
||||
<p className="mt-5 text-[#E3EBE8]/45 text-sm leading-relaxed max-w-xs">
|
||||
Access control, user management, and system configuration — unified in one secure interface.
|
||||
</p>
|
||||
|
||||
{/* Feature pills */}
|
||||
<div className="mt-9 flex flex-col gap-3">
|
||||
{[
|
||||
'Role-based access control',
|
||||
'Real-time audit logs',
|
||||
'Multi-level permissions',
|
||||
].map((feat) => (
|
||||
<div key={feat} className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#D4A017] shrink-0" />
|
||||
<span className="text-[#E3EBE8]/55 text-sm font-medium">{feat}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative z-10 anim-fade" style={{ animationDelay: '0.2s' }}>
|
||||
<p className="text-[#E3EBE8]/25 text-xs">© {new Date().getFullYear()} {appName}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right form panel ─────────────────────────────────────────── */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-white px-8 py-12 min-h-screen">
|
||||
|
||||
{/* Mobile-only logo */}
|
||||
<div className="lg:hidden mb-10 flex items-center gap-3 anim-down">
|
||||
<div className={`w-9 h-9 rounded-[0.6rem] flex items-center justify-center text-sm font-bold overflow-hidden ${!appLogo ? 'bg-[#3D4E4B] text-white' : ''}`}>
|
||||
{appLogo
|
||||
? <img src={appLogo} alt={appName} className="w-full h-full object-contain" />
|
||||
: appLogoText
|
||||
}
|
||||
</div>
|
||||
<span className="text-[#3D4E4B] font-bold text-base tracking-tight">{appName}</span>
|
||||
</div>
|
||||
|
||||
{/* Form slot */}
|
||||
<div className="w-full max-w-[360px]">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="mt-10 anim-fade" style={{ animationDelay: '0.35s' }}>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-gray-300 hover:text-[#3D4E4B] transition-colors duration-200 tracking-tight"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import React from 'react';
|
||||
import { Can } from '@/Components/Can';
|
||||
|
||||
interface NavigationItemProps {
|
||||
href: string;
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
ability?: string | string[];
|
||||
}
|
||||
|
||||
export function NavigationItem({ href, active, icon, label, ability }: NavigationItemProps) {
|
||||
const content = (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--color-primary-50)] text-[var(--color-primary-600)]'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="font-medium hidden md:block">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (ability) {
|
||||
return <Can ability={ability}>{content}</Can>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { usePage, Link } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types';
|
||||
|
||||
export function Sidebar({ theme: _theme }: { theme: string }) {
|
||||
const props = usePage<PageProps>().props as any;
|
||||
const { auth } = props;
|
||||
const { user, permissions } = auth;
|
||||
const path = window.location.pathname;
|
||||
|
||||
const canAccess = (ability: string | null) => {
|
||||
if (!ability) return true;
|
||||
return permissions.includes(ability);
|
||||
};
|
||||
|
||||
const initials = `${user.first_name?.charAt(0) || ''}${user.last_name?.charAt(0) || ''}`.toUpperCase();
|
||||
|
||||
const allGroups = [
|
||||
{
|
||||
label: 'Governance',
|
||||
items: [
|
||||
{
|
||||
href: '/dashboard',
|
||||
label: 'Dashboard',
|
||||
ability: null,
|
||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
|
||||
},
|
||||
{
|
||||
href: '/notifications',
|
||||
label: 'Notifications',
|
||||
ability: 'role.manage',
|
||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Management',
|
||||
items: [
|
||||
{
|
||||
href: '/users',
|
||||
label: 'Users',
|
||||
ability: 'user.view',
|
||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
},
|
||||
{
|
||||
href: '/roles',
|
||||
label: 'Roles',
|
||||
ability: 'role.view',
|
||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
},
|
||||
{
|
||||
href: '/activity-logs',
|
||||
label: 'Activity Logs',
|
||||
ability: 'user.view',
|
||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Account',
|
||||
items: [
|
||||
{
|
||||
href: '/settings',
|
||||
label: 'Settings',
|
||||
ability: null,
|
||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Systems',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{
|
||||
href: '/system-settings',
|
||||
label: 'System Settings',
|
||||
ability: null,
|
||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
},
|
||||
{
|
||||
href: '/documentation',
|
||||
label: 'Dokumentasi',
|
||||
ability: null,
|
||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>
|
||||
},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const canManageSettings = permissions.includes('settings.manage');
|
||||
|
||||
return (
|
||||
<aside className="w-[260px] h-screen p-4 shrink-0 relative z-20 flex flex-col anim-left">
|
||||
<div className="bg-[#3D4E4B] flex-1 rounded-[2rem] flex flex-col relative">
|
||||
|
||||
{/* Profile Section */}
|
||||
<div className="p-8 flex flex-col items-center text-center">
|
||||
<div className="relative mb-4">
|
||||
<div className="w-16 h-16 rounded-full border border-[#D4A017] p-1 flex items-center justify-center">
|
||||
<div className="w-full h-full rounded-full bg-[#2D3A38] overflow-hidden flex items-center justify-center font-bold text-white/40 text-lg">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} className="w-full h-full object-cover" />
|
||||
) : initials}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 w-4 h-4 bg-[#21A59F] border-2 border-[#3D4E4B] rounded-full"></div>
|
||||
</div>
|
||||
<h3 className="text-white text-sm font-bold tracking-tight">{user.first_name} {user.last_name}</h3>
|
||||
<p className="text-[#E3EBE8]/40 text-sm font-medium mt-0.5">{user.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Nav Section with Grouping */}
|
||||
<nav className="flex-1 px-3 space-y-6 overflow-y-auto custom-scrollbar pb-10">
|
||||
{allGroups.map((group) => {
|
||||
if (group.adminOnly && !canManageSettings) return null;
|
||||
const items = group.items.filter(i => canAccess(i.ability));
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.label} className="space-y-1.5">
|
||||
<div className="px-5 text-xs font-semibold text-[#E3EBE8]/30 tracking-widest uppercase mb-2">{group.label}</div>
|
||||
{items.map((item) => {
|
||||
const isActive = path === item.href || path.startsWith(item.href + '/');
|
||||
return (
|
||||
<Link key={item.href} href={item.href}
|
||||
className={`flex items-center gap-4 py-3 transition-[color,background-color,box-shadow,opacity] duration-200 relative group
|
||||
${isActive
|
||||
? 'text-[#3D4E4B] bg-[#E3EBE8] dark:bg-[#1A2120] dark:text-white -mr-3 pr-6 pl-5 rounded-l-[15px] w-[calc(100%+0.75rem)] shadow-sm'
|
||||
: 'text-[#E3EBE8]/60 hover:text-white hover:bg-white/5 px-5 rounded-[15px]'}`}>
|
||||
<span className={`shrink-0 z-10 transition-colors duration-300 ${isActive ? 'text-[#D4A017]' : 'group-hover:text-[#D4A017]'}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tracking-tight z-10">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="p-6 pb-8">
|
||||
<div className="px-4 py-3 rounded-2xl bg-white/5 border border-white/5 flex items-center justify-between group hover:bg-white/10 transition-colors cursor-default">
|
||||
<span className="text-sm font-semibold tracking-tight text-[#D4A017]">biiproject kit</span>
|
||||
<span className="text-sm font-bold text-white">v2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { usePage, Link, router } from '@inertiajs/react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { PageProps } from '@/types';
|
||||
|
||||
export function Topbar({ theme, onThemeToggle, onMenuToggle, sidebarOpen }: { theme: string; onThemeToggle: () => void; onMenuToggle: () => void; sidebarOpen: boolean }) {
|
||||
const props = usePage<PageProps>().props as any;
|
||||
const { auth, system_settings, unread_notifications } = props;
|
||||
const { user, roles } = auth;
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const path = window.location.pathname;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Global Search Logic
|
||||
useEffect(() => {
|
||||
if (searchQuery.length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await fetch(`/api/search?query=${encodeURIComponent(searchQuery)}`);
|
||||
const data = await response.json();
|
||||
setSearchResults(data);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Keyboard Shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
router.post('/logout');
|
||||
};
|
||||
|
||||
const initials = `${user?.first_name?.charAt(0) || ''}${user?.last_name?.charAt(0) || ''}`.toUpperCase();
|
||||
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const formatSegment = (s: string) =>
|
||||
s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
const pageTitle = segments.length > 0 ? formatSegment(segments[0]) : 'Dashboard';
|
||||
const breadcrumbs = segments.length > 1
|
||||
? segments.map((s, i) => ({ label: formatSegment(s), href: '/' + segments.slice(0, i + 1).join('/') }))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<header className="h-16 flex items-center justify-between px-6 shrink-0 relative z-30 transition-colors duration-300 bg-transparent">
|
||||
{/* Left: Breadcrumb area */}
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Burger — mobile only */}
|
||||
<button
|
||||
onClick={onMenuToggle}
|
||||
className="lg:hidden p-2 rounded-xl bg-white dark:bg-white/5 border border-gray-100 dark:border-white/5 text-gray-500 dark:text-gray-400 hover:text-[#3D4E4B] dark:hover:text-white transition-all"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<h2 className="text-sm font-bold text-[#3D4E4B] dark:text-white tracking-tight leading-none">{pageTitle}</h2>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-gray-400 tracking-tight mt-1">
|
||||
<Link href="/dashboard" className="hover:text-[#3D4E4B] dark:hover:text-white transition-colors">
|
||||
{system_settings?.app_name || 'biiproject kit'}
|
||||
</Link>
|
||||
{breadcrumbs ? breadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={crumb.href}>
|
||||
<svg className="w-1 h-1 shrink-0" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" /></svg>
|
||||
{i === breadcrumbs.length - 1
|
||||
? <span className="text-[#D4A017] font-bold">{crumb.label}</span>
|
||||
: <Link href={crumb.href} className="hover:text-[#3D4E4B] dark:hover:text-white transition-colors">{crumb.label}</Link>
|
||||
}
|
||||
</React.Fragment>
|
||||
)) : (
|
||||
<>
|
||||
<svg className="w-1 h-1 shrink-0" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" /></svg>
|
||||
<span className="text-[#D4A017] font-bold">{pageTitle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Search */}
|
||||
<div className="relative group ml-4">
|
||||
<div className="relative w-[300px]">
|
||||
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none group-focus-within:text-[#D4A017] transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search anything... (Ctrl+K)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full h-10 pl-10 pr-4 rounded-xl border border-gray-100 dark:border-white/5 bg-white dark:bg-white/5 text-xs font-bold text-[#3D4E4B] dark:text-white placeholder-gray-400 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all"
|
||||
/>
|
||||
{isSearching && (
|
||||
<div className="absolute right-3.5 top-1/2 -translate-y-1/2">
|
||||
<div className="w-3.5 h-3.5 border-2 border-[#D4A017] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="absolute left-0 right-0 mt-3 bg-white dark:bg-[#232D2B] rounded-2xl shadow-2xl border border-gray-100 dark:border-white/5 overflow-hidden anim-up z-50">
|
||||
<div className="p-2 max-h-[400px] overflow-y-auto custom-scrollbar">
|
||||
{searchResults.map((result, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
href={result.url}
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-all group"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-white/5 flex items-center justify-center text-gray-400 group-hover:text-[#D4A017] transition-colors shrink-0">
|
||||
{result.icon === 'user' && <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>}
|
||||
{result.icon === 'shield' && <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>}
|
||||
{result.icon === 'clock' && <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-[#3D4E4B] dark:text-white tracking-tight">{result.title}</div>
|
||||
<div className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-0.5">{result.type} • {result.subtitle}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions Area */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={onThemeToggle}
|
||||
className="p-2.5 rounded-xl bg-white dark:bg-white/5 text-gray-400 hover:text-[#D4A017] transition-all border border-gray-100 dark:border-white/5"
|
||||
title={`Switch to ${theme === 'dark' ? 'Light' : 'Dark'} Mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707.707M12 5a7 7 0 100 14 7 7 0 000-14z" /></svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Link href="/notifications" className="relative p-2.5 rounded-xl bg-white dark:bg-white/5 text-gray-400 hover:text-[#3D4E4B] dark:hover:text-white transition-all border border-gray-100 dark:border-white/5 flex items-center justify-center">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unread_notifications > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[1rem] h-4 px-0.5 bg-red-500 rounded-full flex items-center justify-center text-[9px] font-black text-white leading-none">
|
||||
{unread_notifications > 99 ? '99+' : unread_notifications}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-3 p-1.5 rounded-xl bg-white dark:bg-white/5 border border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10 transition-all group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-[#3D4E4B] flex items-center justify-center font-bold text-white text-sm">
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} className="w-full h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden sm:block text-left pr-2">
|
||||
<div className="text-xs font-bold text-[#3D4E4B] dark:text-white leading-none">{user?.first_name}</div>
|
||||
<div className="text-[10px] text-[#D4A017] font-black uppercase tracking-widest mt-1">{roles?.[0]?.replace('-', ' ') || 'User'}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-3 w-56 bg-white dark:bg-[#232D2B] rounded-2xl shadow-2xl border border-gray-50 dark:border-white/5 py-2 z-50 anim-up">
|
||||
<div className="px-5 py-3 border-b border-gray-50 dark:border-white/5 mb-1">
|
||||
<div className="text-xs font-black text-[#3D4E4B] dark:text-white tracking-tight">{user?.first_name} {user?.last_name}</div>
|
||||
<div className="text-[10px] text-gray-400 font-bold truncate mt-1 uppercase tracking-widest">{user?.email}</div>
|
||||
</div>
|
||||
<Link href="/profile" className="flex items-center gap-2.5 px-5 py-3 text-xs font-bold text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 transition-all">
|
||||
<svg className="w-4 h-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
Account Settings
|
||||
</Link>
|
||||
<button onClick={handleLogout} className="w-full flex items-center gap-2.5 px-5 py-3 text-xs font-bold text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all text-left border-t border-gray-50 dark:border-white/5 mt-1">
|
||||
<svg className="w-4 h-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
|
||||
Sign Out Session
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user