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
5 changes: 5 additions & 0 deletions app/components/navbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import Navbar from './navbar';
import type { ReactNode } from 'react';

// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
Expand All @@ -17,18 +18,21 @@ Object.defineProperty(window, 'matchMedia', {
})),
});

// Mock framer-motion
vi.mock('framer-motion', () => ({
motion: {
div: ({ children }: { children: ReactNode }) => <div>{children}</div>,
},
}));

// Mock lucide-react icons (including the newly added Search icon)
vi.mock('lucide-react', () => ({
Menu: () => <div>MenuIcon</div>,
X: () => <div>CloseIcon</div>,
Activity: () => <div>ActivityIcon</div>,
Sun: () => <div>SunIcon</div>,
Moon: () => <div>MoonIcon</div>,
Search: () => <div>SearchIcon</div>, // <-- THIS WAS MISSING
}));

describe('Navbar mobile menu', () => {
Expand Down Expand Up @@ -80,6 +84,7 @@ describe('Navbar mobile menu', () => {

window.dispatchEvent(new Event('resize'));

// Note: this assertion tests that the state changed correctly based on your test logic
expect(button.getAttribute('aria-expanded')).toBe('true');
});
});
61 changes: 50 additions & 11 deletions app/components/navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import Link from 'next/link';
import { Menu, X, Activity, Moon, Sun } from 'lucide-react';
import { Menu, X, Activity, Moon, Sun, Search } from 'lucide-react';
import { useGlowEffect } from '@/hooks/useGlowEffect';

function GithubMark() {
Expand All @@ -22,16 +22,25 @@ const NAV_LINKS = [

export default function Navbar() {
const [open, setOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);

// Mounted state to prevent hydration mismatch
const [mounted, setMounted] = useState(false);

const [isDark, setIsDark] = useState(() => {
if (typeof window === 'undefined') return true;

return localStorage.getItem('theme') !== 'light';
});

const { shellRef, shellVars, handleMouseEnter, handleMouseMove, handleMouseLeave } =
useGlowEffect();

// Set mounted to true once the client takes over
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);

useEffect(() => {
document.documentElement.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
Expand All @@ -43,29 +52,34 @@ export default function Navbar() {

useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 768px)');

const handleBreakpointChange = (event: MediaQueryListEvent) => {
if (event.matches) {
setOpen(false);
}
};

// Defer the initial check so it doesn't cause a synchronous setState
// inside the effect body (which would trigger cascading re-renders).
const initialCheckTimer = setTimeout(() => {
if (mediaQuery.matches) {
setOpen(false);
}
}, 0);

mediaQuery.addEventListener('change', handleBreakpointChange);

return () => {
clearTimeout(initialCheckTimer);
mediaQuery.removeEventListener('change', handleBreakpointChange);
};
}, []);

// Listen for the global '/' shortcut
useEffect(() => {
const handleFocusSearch = () => {
searchInputRef.current?.focus();
};
document.addEventListener('focusSearch', handleFocusSearch as EventListener);
return () => {
document.removeEventListener('focusSearch', handleFocusSearch as EventListener);
};
}, []);

const handleLogoClick = () => {
setOpen(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
Expand Down Expand Up @@ -118,13 +132,27 @@ export default function Navbar() {
</Link>

<div className="hidden items-center gap-3 md:flex">
<div className="relative flex items-center">
<Search size={16} className="absolute left-3 text-white/50" />
<input
ref={searchInputRef}
type="text"
placeholder="Search..."
aria-label="Search"
className="h-10 w-48 rounded-xl border border-white/15 bg-white/5 pl-9 pr-8 text-sm text-white placeholder-white/50 transition hover:bg-white/10 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/30"
/>
<kbd className="absolute right-2 top-2.5 hidden sm:inline-block rounded border border-white/20 bg-white/10 px-1.5 font-mono text-[10px] font-medium text-white/50">
/
</kbd>
</div>

<button
type="button"
onClick={toggleTheme}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-white/15 bg-white/5 text-white transition hover:bg-white/10"
aria-label="Toggle theme"
>
{isDark ? <Sun size={18} /> : <Moon size={18} />}
{!mounted ? <Sun size={18} /> : isDark ? <Sun size={18} /> : <Moon size={18} />}
</button>

{NAV_LINKS.map((link) => (
Expand Down Expand Up @@ -154,7 +182,18 @@ export default function Navbar() {

{open ? (
<div className="border-t border-white/10 px-4 py-3 md:hidden">
<ul className="space-y-2">
<ul className="space-y-3">
<li>
<div className="relative flex items-center">
<Search size={16} className="absolute left-3 text-white/50" />
<input
type="text"
placeholder="Search..."
aria-label="Search"
className="h-10 w-full rounded-xl border border-white/15 bg-white/5 pl-9 pr-4 text-sm text-white placeholder-white/50 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</div>
</li>
{NAV_LINKS.map((link) => (
<li key={link.href}>
<a
Expand Down
75 changes: 21 additions & 54 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,69 +4,36 @@
import Navbar from './components/navbar';
import BrandParticles from '@/components/BrandParticles';
import ReturnToTop from '@/components/ReturnToTop';
import type { Metadata } from 'next';

Check warning on line 7 in app/layout.tsx

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'Metadata' is defined but never used

import { ShortcutProvider } from '@/context/ShortcutContext';
import { GlobalShortcutListener } from '@/components/GlobalShortcutListener';

// 1. ADD THESE TWO IMPORTS
import { KeyboardHelpModal } from '@/components/ui/KeyboardHelpModal';
import { CommandPalette } from '@/components/ui/CommandPalette';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
metadataBase: new URL('https://commitpulse.vercel.app'),
title: 'CommitPulse | 3D Isometric GitHub Contribution Graph',
description:
'Transform your GitHub contribution history into a cinematic, 3D isometric SVG monolith. Drop it into your README and visualize your developer rhythm with real-time accuracy.',
keywords: [
'GitHub',
'contribution graph',
'isometric',
'3D SVG',
'GitHub stats',
'README widget',
'developer portfolio',
'CommitPulse',
],
authors: [{ name: 'Sourav Jha', url: 'https://github.com/JhaSourav07' }],
creator: 'Sourav Jha',
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://commitpulse.vercel.app/',
title: 'CommitPulse | 3D Isometric GitHub Contribution Graph',
description:
'Generate a cinematic, isometric 3D SVG of your GitHub contributions for your README. Stop being boring and visualize your grind.',
siteName: 'CommitPulse',
images: [
{
url: 'https://commitpulse.vercel.app/api/streak?user=jhasourav07&theme=neon',
width: 1200,
height: 630,
alt: 'CommitPulse 3D GitHub Contribution Graph Preview',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'CommitPulse | Elevate Your GitHub README',
description:
'Generate a cinematic, isometric 3D SVG of your GitHub contributions for your README.',
images: ['https://commitpulse.vercel.app/api/streak?user=jhasourav07&theme=neon'],
// creator: '@your_twitter_handle', // Uncomment and add your Twitter handle here
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
// ... (keep your existing metadata config) ...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={`${inter.className} bg-black`}>
<ShortcutProvider>
<GlobalShortcutListener />

{/* 2. MOUNT THE MODALS HERE SO THEY CAN RENDER */}
<KeyboardHelpModal />
<CommandPalette />

<BrandParticles />
<Navbar />
<div className="pt-24 sm:pt-28 relative z-10">{children}</div>
<ReturnToTop />
<Analytics />
</ShortcutProvider>
<BrandParticles />
<Navbar />
<div className="relative z-10">{children}</div>
Expand Down
20 changes: 20 additions & 0 deletions components/GlobalShortcutListener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client';

import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useShortcutContext } from '../context/ShortcutContext';

export const GlobalShortcutListener = () => {
const { openHelp, closeAll, openCommandPalette } = useShortcutContext();

useKeyboardShortcuts({
onOpenHelp: openHelp,
onCloseModal: closeAll,
onOpenCommandPalette: openCommandPalette,
onFocusSearch: () => {
// Dispatch a custom event so the Search component can catch it anywhere in the app
document.dispatchEvent(new CustomEvent('focusSearch'));
},
});

return null; // This component is purely logical and renders nothing
};
Loading
Loading