Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions app/(admin)/_components/AdminCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React from "react";
import { cn } from "./utils";

type CardProps = {
title?: string;
subtitle?: string;
className?: string;
children: React.ReactNode;
};

export function Card({ title, subtitle, className, children }: CardProps) {
return (
<section
className={cn(
"rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900",
className,
)}
>
{title ? (
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{title}
</h3>
) : null}
{subtitle ? (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{subtitle}
</p>
) : null}
<div className={title || subtitle ? "mt-4" : ""}>{children}</div>
</section>
);
}

type StatCardProps = {
label: string;
value: string;
trend?: string;
};

export function StatCard({ label, value, trend }: StatCardProps) {
return (
<Card className="p-4 sm:p-5">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</p>
<p className="mt-2 text-2xl font-semibold text-slate-900 dark:text-slate-100">
{value}
</p>
{trend ? (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{trend}
</p>
) : null}
</Card>
);
}

export function PageTitle({
title,
description,
}: {
title: string;
description?: string;
}) {
return (
<div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100 sm:text-2xl">
{title}
</h1>
{description ? (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{description}
</p>
) : null}
</div>
);
}

const statusStyles: Record<
"success" | "warning" | "danger" | "neutral",
string
> = {
success:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300",
warning:
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300",
danger:
"border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-300",
neutral:
"border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-300",
};

export function StatusBadge({
label,
tone,
}: {
label: string;
tone: "success" | "warning" | "danger" | "neutral";
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium",
statusStyles[tone],
)}
>
{label}
</span>
);
}

export function ActionButton({
children,
variant = "secondary",
}: {
children: React.ReactNode;
variant?: "primary" | "secondary" | "danger";
}) {
const variants: Record<typeof variant, string> = {
primary:
"border-slate-900 bg-slate-900 text-white hover:bg-slate-800 dark:border-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600",
secondary:
"border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700",
danger:
"border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-300 dark:hover:bg-rose-900",
};

return (
<button
type="button"
className={cn(
"inline-flex items-center rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors duration-200",
variants[variant],
)}
>
{children}
</button>
);
}
121 changes: 121 additions & 0 deletions app/(admin)/_components/AdminNavbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";

import {
Bell,
Menu,
Moon,
PanelLeftClose,
PanelLeftOpen,
Search,
Sun,
} from "lucide-react";
import { usePathname } from "next/navigation";

const titleMap: Record<string, string> = {
"/admin": "Dashboard",
"/admin/incident-reports": "Incident Reports",
"/admin/agencies": "Agencies",
"/admin/users": "Users",
"/admin/notifications": "Notifications",
"/admin/analytics": "Analytics",
"/admin/data-export": "Data Export",
"/admin/subscriptions": "Subscriptions",
"/admin/settings": "Settings",
};

export default function AdminNavbar({
onMenuClick,
isDesktopCollapsed,
onToggleDesktopCollapse,
theme,
onToggleTheme,
}: {
onMenuClick: () => void;
isDesktopCollapsed: boolean;
onToggleDesktopCollapse: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
}) {
const pathname = usePathname();
const title = titleMap[pathname] || "Admin";

return (
<header className="sticky top-0 z-20 border-b border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90">
<div className="flex h-16 items-center gap-3 px-4 sm:px-6">
<button
type="button"
onClick={onMenuClick}
className="rounded-lg border border-slate-200 p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 lg:hidden"
aria-label="Open sidebar"
>
<Menu className="h-4 w-4" />
</button>

<button
type="button"
onClick={onToggleDesktopCollapse}
className="hidden rounded-lg border border-slate-200 p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 lg:inline-flex"
aria-label="Toggle desktop sidebar"
>
{isDesktopCollapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>

<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{title}
</p>
<p className="hidden text-xs text-slate-500 dark:text-slate-400 sm:block">
Disaster response management
</p>
</div>

<label className="hidden w-full max-w-md items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 md:flex">
<Search className="h-4 w-4" />
<input
type="search"
placeholder="Search incidents, agencies, users..."
className="w-full bg-transparent outline-none placeholder:text-slate-400 dark:placeholder:text-slate-500"
/>
</label>

<button
type="button"
onClick={onToggleTheme}
className="rounded-lg border border-slate-200 p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>

<button
type="button"
className="relative rounded-lg border border-slate-200 p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
aria-label="Notifications"
>
<Bell className="h-4 w-4" />
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-rose-500" />
</button>

<button
type="button"
className="flex items-center gap-2 rounded-full border border-slate-200 p-1 pr-3 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">
AD
</span>
<span className="hidden text-sm font-medium text-slate-700 dark:text-slate-200 sm:block">
Admin
</span>
</button>
</div>
</header>
);
}
104 changes: 104 additions & 0 deletions app/(admin)/_components/AdminSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { X } from "lucide-react";
import { navItems } from "./adminData";
import { cn } from "./utils";

type AdminSidebarProps = {
isOpen: boolean;
onClose: () => void;
isDesktopCollapsed: boolean;
};

export default function AdminSidebar({
isOpen,
onClose,
isDesktopCollapsed,
}: AdminSidebarProps) {
const pathname = usePathname();

return (
<>
{/* Mobile overlay */}
<div
className={cn(
"fixed inset-0 z-30 bg-slate-900/40 backdrop-blur-sm transition-opacity duration-200 lg:hidden",
isOpen ? "opacity-100" : "pointer-events-none opacity-0",
)}
onClick={onClose}
/>

{/* Sidebar */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 border-r border-slate-200 bg-white p-4 transition-all duration-300 dark:border-slate-800 dark:bg-slate-900 lg:static lg:z-0 lg:translate-x-0",
isDesktopCollapsed ? "lg:w-20" : "lg:w-72",
"w-72",
isOpen ? "translate-x-0" : "-translate-x-full",
)}
>
{/* Header section - hidden on desktop collapse */}
<div className={cn(isDesktopCollapsed && "lg:hidden")}>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Disaster Response
</p>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Admin Panel
</h2>
</div>

{/* Collapsed badge - shown only when desktop collapsed */}
<div
className={cn(
"hidden h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-xs font-semibold text-white lg:flex",
!isDesktopCollapsed && "lg:hidden",
)}
>
AD
</div>

{/* Close button - mobile only */}
<button
type="button"
className="rounded-lg border border-slate-200 p-2 text-slate-500 dark:border-slate-700 dark:text-slate-400 lg:hidden"
onClick={onClose}
aria-label="Close sidebar"
>
<X className="h-4 w-4" />
</button>

{/* Navigation */}
<nav className="space-y-1">
{navItems.map(({ label, href, icon: Icon }) => {
const isActive =
href === "/admin"
? pathname === href
: pathname === href || pathname.startsWith(href + "/");

return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200",
isDesktopCollapsed && "lg:justify-center lg:px-2",
isActive
? "bg-slate-900 text-white shadow-sm dark:bg-slate-800"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100",
)}
title={isDesktopCollapsed ? label : undefined}
>
<Icon className="h-4 w-4" />
<span className={cn(isDesktopCollapsed && "lg:hidden")}>
{label}
</span>
</Link>
);
})}
</nav>
</aside>
</>
);
}
Loading