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
177 changes: 73 additions & 104 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useRef, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import ThemeToggle from "@/components/ThemeToggle";
Expand All @@ -9,6 +9,7 @@ import NotificationCenter from "@/components/NotificationCenter";
import HeaderShortcutsButton from "@/components/HeaderShortcutsButton";
import NetworkStatus from "@/components/NetworkStatus";
import GlobalSearch from "@/components/GlobalSearch";
import MobileSidebar from "@/components/layout/MobileSidebar";

const NAV_LINKS = [
{ href: "/dashboard", label: "Dashboard" },
Expand All @@ -20,125 +21,93 @@ const NAV_LINKS = [
];

export default function Navbar() {
const [menuOpen, setMenuOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
const hamburgerRef = useRef<HTMLButtonElement>(null);

const isActive = (href: string) => pathname === href;

return (
<header className="sticky top-0 z-40 w-full border-b border-white/[0.06] bg-surface-900/95 backdrop-blur-md">
<div className="mx-auto flex max-w-7xl items-center gap-3 px-4 sm:px-6 h-14">
<>
<header className="sticky top-0 z-40 w-full border-b border-white/[0.06] bg-surface-900/95 backdrop-blur-md">
<div className="mx-auto flex max-w-7xl items-center gap-3 px-4 sm:px-6 h-14">

{/* Logo */}
<Link
href="/"
className="mr-4 flex items-center gap-2 shrink-0 group"
aria-label="StellarSplit home"
>
{/* Star icon */}
<span
className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-brand shadow-glow-sm text-white text-sm select-none"
aria-hidden="true"
{/* Logo */}
<Link
href="/"
className="mr-4 flex items-center gap-2 shrink-0 group"
aria-label="StellarSplit home"
>
</span>
<span className="font-bold text-base tracking-tight text-white group-hover:text-brand-300 transition-colors">
StellarSplit
</span>
</Link>
<span
className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-brand shadow-glow-sm text-white text-sm select-none"
aria-hidden="true"
>
</span>
<span className="font-bold text-base tracking-tight text-white group-hover:text-brand-300 transition-colors">
StellarSplit
</span>
</Link>

{/* Desktop nav links */}
<nav className="hidden md:flex items-center gap-1 flex-1" aria-label="Main navigation">
{NAV_LINKS.map(({ href, label }) => (
{/* Desktop nav links — visible on lg and wider */}
<nav className="hidden lg:flex items-center gap-1 flex-1" aria-label="Main navigation">
{NAV_LINKS.map(({ href, label }) => (
<Link
key={href}
href={href}
className={[
"px-3 py-1.5 rounded-md text-small font-medium transition-colors",
isActive(href)
? "bg-brand-600/20 text-brand-300"
: "text-slate-400 hover:text-white hover:bg-white/[0.06]",
].join(" ")}
>
{label}
</Link>
))}
</nav>

{/* Right-side actions */}
<div className="ml-auto flex items-center gap-1">
<div className="hidden sm:flex items-center gap-2">
<NetworkStatus />
<SimulationModeToggle />
<NotificationCenter />
<HeaderShortcutsButton />
<ThemeToggle />
</div>

{/* New Invoice CTA — hidden below lg */}
<Link
key={href}
href={href}
className={[
"px-3 py-1.5 rounded-md text-small font-medium transition-colors",
isActive(href)
? "bg-brand-600/20 text-brand-300"
: "text-slate-400 hover:text-white hover:bg-white/[0.06]",
].join(" ")}
href="/invoice/new"
className="hidden lg:inline-flex items-center gap-1.5 ml-2 h-8 px-3.5 rounded-md bg-brand-600 hover:bg-brand-500 active:bg-brand-700 text-white text-small font-semibold transition-colors shadow-glow-sm"
>
{label}
<span aria-hidden="true">+</span> New Invoice
</Link>
))}
</nav>

{/* Right-side actions */}
<div className="ml-auto flex items-center gap-1">
<div className="hidden sm:flex items-center gap-2">
<NetworkStatus />
<SimulationModeToggle />
<NotificationCenter />
<HeaderShortcutsButton />
<ThemeToggle />
</div>

{/* New Invoice CTA */}
<Link
href="/invoice/new"
className="hidden sm:inline-flex items-center gap-1.5 ml-2 h-8 px-3.5 rounded-md bg-brand-600 hover:bg-brand-500 active:bg-brand-700 text-white text-small font-semibold transition-colors shadow-glow-sm"
>
<span aria-hidden="true">+</span> New Invoice
</Link>

{/* Mobile menu toggle */}
<button
className="md:hidden ml-1 flex items-center justify-center h-9 w-9 rounded-md text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors"
aria-label={menuOpen ? "Close menu" : "Open menu"}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
>
{menuOpen ? (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 2l14 14M16 2 2 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : (
{/* Hamburger button — visible below lg */}
<button
ref={hamburgerRef}
className="lg:hidden ml-1 flex items-center justify-center h-9 w-9 rounded-md text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors"
aria-label="Open navigation menu"
aria-expanded={sidebarOpen}
aria-controls="mobile-sidebar"
onClick={() => setSidebarOpen(true)}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 5h14M2 9h14M2 13h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
</button>
</button>
</div>
</div>
</div>
</header>

{/* Mobile drawer */}
{menuOpen && (
<nav
className="md:hidden border-t border-white/[0.06] bg-surface-900 px-4 py-3 flex flex-col gap-1"
aria-label="Mobile navigation"
>
{NAV_LINKS.map(({ href, label }) => (
<Link
key={href}
href={href}
onClick={() => setMenuOpen(false)}
className={[
"px-3 py-2.5 rounded-md text-small font-medium transition-colors",
isActive(href)
? "bg-brand-600/20 text-brand-300"
: "text-slate-400 hover:text-white hover:bg-white/[0.06]",
].join(" ")}
>
{label}
</Link>
))}
<Link
href="/invoice/new"
onClick={() => setMenuOpen(false)}
className="mt-2 flex items-center justify-center gap-1.5 h-10 rounded-md bg-brand-600 hover:bg-brand-500 text-white text-small font-semibold transition-colors"
>
+ New Invoice
</Link>
<div className="flex items-center gap-1 pt-2 border-t border-white/[0.06] mt-1">
<SimulationModeToggle />
<NotificationCenter />
<HeaderShortcutsButton />
<ThemeToggle />
</div>
</nav>
)}
</header>
{/* Slide-in sidebar for below lg */}
<MobileSidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
triggerRef={hamburgerRef}
/>
</>
);
}
146 changes: 146 additions & 0 deletions src/components/layout/MobileSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"use client";

import { useEffect, useRef } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import FocusTrap from "@/components/FocusTrap";

const NAV_LINKS = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/subscriptions", label: "Subscriptions" },
{ href: "/groups", label: "Groups" },
{ href: "/address-book", label: "Contacts" },
{ href: "/recipients", label: "Recipients" },
{ href: "/leaderboard", label: "Leaderboard" },
];

interface MobileSidebarProps {
isOpen: boolean;
onClose: () => void;
triggerRef: React.RefObject<HTMLButtonElement>;
}

export default function MobileSidebar({ isOpen, onClose, triggerRef }: MobileSidebarProps) {
const pathname = usePathname();

const isActive = (href: string) => pathname === href;

// Close on Escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
triggerRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose, triggerRef]);

// Prevent body scroll when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);

return (
<>
{/* Backdrop */}
<div
aria-hidden="true"
onClick={() => {
onClose();
triggerRef.current?.focus();
}}
className={[
"fixed inset-0 z-40 bg-black/60 transition-opacity duration-200 lg:hidden",
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none",
].join(" ")}
/>

{/* Sidebar panel */}
<div
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
className={[
"fixed inset-y-0 left-0 z-50 w-72 bg-surface-900 border-r border-white/[0.06] flex flex-col",
"transition-transform duration-200 ease-in-out lg:hidden",
isOpen ? "translate-x-0" : "-translate-x-full",
].join(" ")}
>
{isOpen && (
<FocusTrap onClose={() => { onClose(); triggerRef.current?.focus(); }}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-white/[0.06]">
<Link
href="/"
onClick={onClose}
className="flex items-center gap-2 group"
aria-label="StellarSplit home"
>
<span
className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-brand shadow-glow-sm text-white text-sm select-none"
aria-hidden="true"
>
</span>
<span className="font-bold text-base tracking-tight text-white group-hover:text-brand-300 transition-colors">
StellarSplit
</span>
</Link>
<button
onClick={() => { onClose(); triggerRef.current?.focus(); }}
className="flex items-center justify-center h-9 w-9 rounded-md text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors"
aria-label="Close navigation menu"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 2l14 14M16 2 2 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>

{/* Nav links */}
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="Mobile navigation">
<ul className="flex flex-col gap-1" role="list">
{NAV_LINKS.map(({ href, label }) => (
<li key={href}>
<Link
href={href}
onClick={onClose}
className={[
"block px-3 py-2.5 rounded-md text-sm font-medium transition-colors",
isActive(href)
? "bg-brand-600/20 text-brand-300"
: "text-slate-400 hover:text-white hover:bg-white/[0.06]",
].join(" ")}
>
{label}
</Link>
</li>
))}
</ul>

<div className="mt-4 pt-4 border-t border-white/[0.06]">
<Link
href="/invoice/new"
onClick={onClose}
className="flex items-center justify-center gap-1.5 h-10 rounded-md bg-brand-600 hover:bg-brand-500 text-white text-sm font-semibold transition-colors"
>
<span aria-hidden="true">+</span> New Invoice
</Link>
</div>
</nav>
</FocusTrap>
)}
</div>
</>
);
}