Skip to content

Commit a0fa931

Browse files
authored
feat: add navigation loading states and responsive GitHub button (#301)
1 parent 9614592 commit a0fa931

16 files changed

Lines changed: 386 additions & 153 deletions

frontend/components/blog/BlogCategoryLinks.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

3-
import { Home } from 'lucide-react';
3+
import { BookOpen } from 'lucide-react';
44
import { useTranslations } from 'next-intl';
55

6+
import { useMobileMenu } from '@/components/header/MobileMenuContext';
67
import { AnimatedNavLink } from '@/components/shared/AnimatedNavLink';
78
import { HeaderButton } from '@/components/shared/HeaderButton';
89
import { usePathname } from '@/i18n/routing';
@@ -29,6 +30,7 @@ export function BlogCategoryLinks({
2930
const t = useTranslations('blog');
3031
const tNav = useTranslations('navigation');
3132
const pathname = usePathname();
33+
const { startNavigation } = useMobileMenu();
3234

3335
const getCategoryLabel = (categoryName: string): string => {
3436
const key = categoryName.toLowerCase() as
@@ -60,8 +62,17 @@ export function BlogCategoryLinks({
6062
className={cn('flex items-center gap-2', className)}
6163
aria-label="Blog categories"
6264
>
63-
<HeaderButton href="/" onClick={onNavigate} icon={Home}>
64-
{tNav('home')}
65+
<HeaderButton
66+
href="/blog"
67+
onLinkClick={e => {
68+
e.preventDefault();
69+
if (onNavigate) onNavigate();
70+
startNavigation('/blog');
71+
}}
72+
icon={BookOpen}
73+
isActive={pathname === '/blog'}
74+
>
75+
{tNav('blog')}
6576
</HeaderButton>
6677

6778
{items.map(category => {

frontend/components/header/AppMobileMenu.tsx

Lines changed: 91 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use client';
22

3-
import { Home, LogIn, Menu, ShoppingBag, X } from 'lucide-react';
3+
import { BookOpen, LogIn, Menu, ShoppingBag, X } from 'lucide-react';
44
import { useSearchParams } from 'next/navigation';
55
import { useTranslations } from 'next-intl';
6-
import { useEffect, useMemo, useState } from 'react';
6+
import { useEffect, useMemo } from 'react';
77

88
import { LogoutButton } from '@/components/auth/logoutButton';
9+
import { useMobileMenu } from '@/components/header/MobileMenuContext';
910
import { HeaderButton } from '@/components/shared/HeaderButton';
1011
import { Link, usePathname } from '@/i18n/routing';
1112
import { SITE_LINKS } from '@/lib/navigation';
@@ -32,6 +33,31 @@ export function AppMobileMenu({
3233
const tProducts = useTranslations('shop.products');
3334
const tBlog = useTranslations('blog');
3435
const pathname = usePathname();
36+
const searchParams = useSearchParams();
37+
38+
const {
39+
isOpen: open,
40+
isAnimating,
41+
close,
42+
toggle,
43+
startNavigation,
44+
} = useMobileMenu();
45+
46+
const currentCategory = searchParams.get('category');
47+
48+
const handleLinkClick = (
49+
e: React.MouseEvent<HTMLAnchorElement>,
50+
href: string
51+
) => {
52+
e.preventDefault();
53+
startNavigation(href);
54+
};
55+
56+
const handleHeaderButtonLinkClick =
57+
(href: string) => (e: React.MouseEvent<HTMLAnchorElement>) => {
58+
e.preventDefault();
59+
startNavigation(href);
60+
};
3561

3662
const getBlogCategoryLabel = (categoryName: string): string => {
3763
const key = categoryName.toLowerCase() as
@@ -40,45 +66,16 @@ export function AppMobileMenu({
4066
| 'insights'
4167
| 'news'
4268
| 'growth';
43-
const categoryTranslations: Record<string, string> = {
69+
const translations: Record<string, string> = {
4470
tech: tBlog('categories.tech'),
4571
career: tBlog('categories.career'),
4672
insights: tBlog('categories.insights'),
4773
news: tBlog('categories.news'),
4874
growth: tBlog('categories.growth'),
4975
};
50-
return categoryTranslations[key] || categoryName;
51-
};
52-
const searchParams = useSearchParams();
53-
const currentCategory = searchParams.get('category');
54-
const [open, setOpen] = useState(false);
55-
const [isAnimating, setIsAnimating] = useState(false);
56-
57-
const close = () => {
58-
setIsAnimating(false);
59-
setTimeout(() => setOpen(false), 200);
60-
};
61-
62-
const toggle = () => {
63-
if (open) {
64-
close();
65-
} else {
66-
setOpen(true);
67-
setTimeout(() => setIsAnimating(true), 10);
68-
}
76+
return translations[key] || categoryName;
6977
};
7078

71-
useEffect(() => {
72-
if (!open) return;
73-
74-
const onKeyDown = (e: KeyboardEvent) => {
75-
if (e.key === 'Escape') close();
76-
};
77-
78-
window.addEventListener('keydown', onKeyDown);
79-
return () => window.removeEventListener('keydown', onKeyDown);
80-
}, [open]);
81-
8279
const shopLinks = useMemo(
8380
() => [
8481
{ href: '/shop/products', label: tProducts('title'), category: null },
@@ -107,40 +104,43 @@ export function AppMobileMenu({
107104
return [];
108105
}, [variant, shopLinks]);
109106

107+
const slugify = (value: string) =>
108+
value
109+
.toLowerCase()
110+
.trim()
111+
.replace(/[^a-z0-9\s-]/g, '')
112+
.replace(/\s+/g, '-');
113+
114+
const linkClass = (isActive: boolean) =>
115+
`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
116+
isActive
117+
? 'text-[var(--accent-primary)]'
118+
: 'text-muted-foreground active:text-[var(--accent-hover)]'
119+
}`;
120+
121+
// Lock body scroll when menu is open
110122
useEffect(() => {
111123
if (open) {
112124
const scrollY = window.scrollY;
113-
114-
document.body.style.position = 'fixed';
115-
document.body.style.top = `-${scrollY}px`;
116-
document.body.style.width = '100%';
117-
document.body.style.overflow = 'hidden';
125+
Object.assign(document.body.style, {
126+
position: 'fixed',
127+
top: `-${scrollY}px`,
128+
width: '100%',
129+
overflow: 'hidden',
130+
});
118131

119132
return () => {
120-
document.body.style.position = '';
121-
document.body.style.top = '';
122-
document.body.style.width = '';
123-
document.body.style.overflow = '';
124-
133+
Object.assign(document.body.style, {
134+
position: '',
135+
top: '',
136+
width: '',
137+
overflow: '',
138+
});
125139
window.scrollTo(0, scrollY);
126140
};
127141
}
128142
}, [open]);
129143

130-
const linkClass = (isActive: boolean) =>
131-
`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
132-
isActive
133-
? 'text-[var(--accent-primary)]'
134-
: 'text-muted-foreground active:text-[var(--accent-hover)]'
135-
}`;
136-
137-
const slugify = (value: string) =>
138-
value
139-
.toLowerCase()
140-
.trim()
141-
.replace(/[^a-z0-9\s-]/g, '')
142-
.replace(/\s+/g, '-');
143-
144144
return (
145145
<>
146146
<button
@@ -151,7 +151,7 @@ export function AppMobileMenu({
151151
color: open ? 'var(--accent-primary)' : 'var(--muted-foreground)',
152152
}}
153153
aria-label={tAria('toggleMenu')}
154-
aria-expanded={open ? 'true' : 'false'}
154+
aria-expanded={open}
155155
aria-controls={open ? 'app-mobile-nav' : undefined}
156156
>
157157
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
@@ -178,65 +178,77 @@ export function AppMobileMenu({
178178
}}
179179
>
180180
<div className="flex flex-col gap-1">
181-
{variant === 'shop' ? (
181+
{variant === 'shop' && (
182182
<>
183-
<HeaderButton href="/" icon={Home} onClick={close}>
184-
{t('home')}
183+
<HeaderButton
184+
href="/shop"
185+
icon={ShoppingBag}
186+
isActive={pathname === '/shop'}
187+
onLinkClick={handleHeaderButtonLinkClick('/shop')}
188+
>
189+
{t('shop')}
185190
</HeaderButton>
186191
{links.map(link => {
187192
const isActive =
188193
pathname === '/shop/products' &&
189194
('category' in link
190195
? link.category === currentCategory
191196
: currentCategory === null);
197+
192198
return (
193199
<Link
194200
key={link.href}
195201
href={link.href}
196-
onClick={close}
202+
onClick={e => handleLinkClick(e, link.href)}
197203
className={linkClass(isActive)}
198204
>
199205
{'labelKey' in link ? t(link.labelKey) : link.label}
200206
</Link>
201207
);
202208
})}
203209
</>
204-
) : null}
210+
)}
205211

206-
{variant === 'blog' ? (
212+
{variant === 'blog' && (
207213
<>
208-
<HeaderButton href="/" icon={Home} onClick={close}>
209-
{t('home')}
214+
<HeaderButton
215+
href="/blog"
216+
icon={BookOpen}
217+
isActive={pathname === '/blog'}
218+
onLinkClick={handleHeaderButtonLinkClick('/blog')}
219+
>
220+
{t('blog')}
210221
</HeaderButton>
211222
{blogCategories.map(category => {
212223
const slug = slugify(category.title || '');
213224
const href = `/blog/category/${slug}`;
214225
const isActive = pathname === href;
215226
const displayTitle =
216227
category.title === 'Growth' ? 'Career' : category.title;
228+
217229
return (
218230
<Link
219231
key={category._id}
220232
href={href}
221-
onClick={close}
233+
onClick={e => handleLinkClick(e, href)}
222234
className={linkClass(isActive)}
223235
>
224236
{getBlogCategoryLabel(displayTitle)}
225237
</Link>
226238
);
227239
})}
228240
</>
229-
) : null}
241+
)}
230242

231-
{variant === 'platform' ? (
243+
{variant === 'platform' && (
232244
<>
233245
{links
234246
.filter(link => link.href !== '/shop')
235247
.map(link => (
236248
<Link
237249
key={link.href}
238250
href={link.href}
239-
onClick={close}
251+
onClick={e => handleLinkClick(e, link.href)}
240252
className={linkClass(pathname === link.href)}
241253
>
242254
{'labelKey' in link ? t(link.labelKey) : link.label}
@@ -247,46 +259,46 @@ export function AppMobileMenu({
247259
href="/shop"
248260
icon={ShoppingBag}
249261
showArrow
250-
onClick={close}
262+
onLinkClick={handleHeaderButtonLinkClick('/shop')}
251263
>
252264
{t('shop')}
253265
</HeaderButton>
254266
</>
255-
) : null}
267+
)}
256268

257-
{variant === 'shop' && showAdminLink ? (
269+
{variant === 'shop' && showAdminLink && (
258270
<Link
259271
href="/shop/admin/products/new"
260-
onClick={close}
272+
onClick={e => handleLinkClick(e, '/shop/admin/products/new')}
261273
className={linkClass(pathname === '/shop/admin/products/new')}
262274
>
263275
{tMobileMenu('newProduct')}
264276
</Link>
265-
) : null}
277+
)}
266278

267279
<div className="bg-border my-2 h-px" />
268280

269281
{userExists ? (
270282
<>
271283
<Link
272284
href="/dashboard"
273-
onClick={close}
285+
onClick={e => handleLinkClick(e, '/dashboard')}
274286
className={linkClass(pathname === '/dashboard')}
275287
>
276288
{t('dashboard')}
277289
</Link>
278290

279-
{showAdminLink ? (
291+
{showAdminLink && (
280292
<Link
281293
href="/shop/admin"
282294
aria-label={tAria('shopAdmin')}
283295
title={tAria('shopAdmin')}
284-
onClick={close}
296+
onClick={e => handleLinkClick(e, '/shop/admin')}
285297
className={linkClass(pathname === '/shop/admin')}
286298
>
287299
{tMobileMenu('admin')}
288300
</Link>
289-
) : null}
301+
)}
290302

291303
<div onClick={close}>
292304
<LogoutButton />

frontend/components/header/DesktopActions.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
55

66
import { LogoutButton } from '@/components/auth/logoutButton';
77
import { BlogHeaderSearch } from '@/components/blog/BlogHeaderSearch';
8-
import { GitHubStarButton } from '@/components/shared/GitHubStarButton';
98
import { HeaderButton } from '@/components/shared/HeaderButton';
109
import LanguageSwitcher from '@/components/shared/LanguageSwitcher';
1110
import { CartButton } from '@/components/shop/header/CartButton';
@@ -49,7 +48,6 @@ export function DesktopActions({
4948
{isBlog && <BlogHeaderSearch />}
5049

5150
<LanguageSwitcher />
52-
<GitHubStarButton />
5351

5452
{isShop && <CartButton />}
5553

0 commit comments

Comments
 (0)