Skip to content

Commit 51259b8

Browse files
ericyangpanclaude
andcommitted
refactor(components): extract mega menu components and update i18n usage
Extract mega menu components to dedicated files and update all components to use the new namespaced i18n structure. Changes: - Extract mega menu components: - Move RankingMegaMenu from src/components/ → src/components/controls/ - Move StackMegaMenu from src/components/ → src/components/controls/ - Update all components to use new namespaced i18n translations - Update Header and Footer with new i18n structure - Update control components (Breadcrumb, LanguageSwitcher, SearchDialog, SearchInput, ThemeSwitcher) - Update navigation components (StackTabs) - Maintain minimalist design system throughout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5de20b2 commit 51259b8

File tree

10 files changed

+92
-83
lines changed

10 files changed

+92
-83
lines changed

src/components/Footer.tsx

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { getTranslations } from 'next-intl/server'
1+
'use client'
2+
3+
import { useTranslations } from 'next-intl'
4+
import LanguageSwitcher from '@/components/controls/LanguageSwitcher'
5+
import ThemeSwitcher from '@/components/controls/ThemeSwitcher'
26
import { Link } from '@/i18n/navigation'
3-
import LanguageSwitcher from './controls/LanguageSwitcher'
4-
import ThemeSwitcher from './controls/ThemeSwitcher'
57

68
// Footer link list component to reduce code duplication
79
interface FooterLinkListProps {
@@ -32,11 +34,11 @@ function FooterLinkList({ title, links }: FooterLinkListProps) {
3234
)
3335
}
3436

35-
export default async function Footer() {
36-
const tFooter = await getTranslations('footer')
37-
const tCommunity = await getTranslations('community')
38-
const tStacks = await getTranslations('stacks')
39-
const tHeader = await getTranslations('header')
37+
export default function Footer() {
38+
const tFooter = useTranslations('components.footer')
39+
const tPlatforms = useTranslations('shared.platforms')
40+
const tStacks = useTranslations('shared.stacks')
41+
const tCommon = useTranslations('shared.common')
4042

4143
// Define link arrays (static hrefs, only labels depend on translations)
4244
const resourceLinks = [
@@ -49,58 +51,52 @@ export default async function Footer() {
4951
]
5052

5153
const documentationLinks = [
52-
{ href: '/docs', label: tFooter('docs') },
53-
{ href: '/articles', label: tFooter('articles') },
54-
{ href: '/curated-collections', label: tFooter('curatedCollections') },
55-
{ href: '/#faq', label: tFooter('faq') },
54+
{ href: '/docs', label: tCommon('docs') },
55+
{ href: '/articles', label: tCommon('articles') },
56+
{ href: '/curated-collections', label: tCommon('curatedCollections') },
57+
{ href: '/#faq', label: tCommon('faq') },
5658
]
5759

5860
const communityLinks = [
5961
{
6062
href: 'https://github.com/aicodingstack/aicodingstack.io',
61-
label: tCommunity('github'),
63+
label: tPlatforms('github'),
6264
isExternal: true,
6365
},
6466
{
6567
href: 'https://aicodingstack.io/discord',
66-
label: tCommunity('discord'),
68+
label: tPlatforms('discord'),
6769
isExternal: true,
6870
},
6971
{
7072
href: 'https://x.com/aicodingstack',
71-
label: tCommunity('twitter'),
73+
label: tPlatforms('twitter'),
7274
isExternal: false,
7375
},
7476
]
7577

7678
return (
77-
<footer className="bg-[var(--color-bg)] border-t border-[var(--color-border)] py-[var(--spacing-xl)] pb-[var(--spacing-md)]">
78-
<div className="max-w-8xl mx-auto px-[var(--spacing-md)]">
79-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-9 gap-[var(--spacing-lg)] mb-[var(--spacing-lg)]">
80-
<div className="flex flex-col gap-[var(--spacing-sm)] lg:col-span-3">
81-
<span className="text-sm font-semibold tracking-tight">{tHeader('aiCodingStack')}</span>
82-
<p className="text-sm pb-[var(--spacing-sm)] leading-[1.8] text-[var(--color-text-secondary)] font-light">
83-
{tFooter('tagline')}
84-
<span className="block mt-[var(--spacing-sm)]">{tFooter('openSource')}</span>
85-
</p>
86-
<div className="flex gap-[var(--spacing-xs)]">
87-
<ThemeSwitcher />
88-
<LanguageSwitcher />
89-
</div>
79+
<footer className="bg-[var(--color-bg)] max-w-8xl mx-auto px-[var(--spacing-md)] mt-[var(--spacing-lg)]">
80+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-9 gap-[var(--spacing-lg)] py-[var(--spacing-lg)] border-y border-[var(--color-border)]">
81+
<div className="flex flex-col gap-[var(--spacing-sm)] lg:col-span-3">
82+
<span className="text-sm font-semibold tracking-tight">{tCommon('aiCodingStack')}</span>
83+
<p className="text-sm pb-[var(--spacing-sm)] leading-[1.8] text-[var(--color-text-secondary)] font-light">
84+
{tFooter('tagline')}
85+
<span className="block mt-[var(--spacing-sm)]">{tFooter('openSource')}</span>
86+
</p>
87+
<div className="flex gap-[var(--spacing-xs)]">
88+
<ThemeSwitcher />
89+
<LanguageSwitcher />
9090
</div>
91-
92-
<FooterLinkList title={tFooter('resources')} links={resourceLinks} />
93-
<FooterLinkList title={tFooter('documentation')} links={documentationLinks} />
94-
<FooterLinkList title={tFooter('community')} links={communityLinks} />
9591
</div>
9692

97-
<div className="border-t border-[var(--color-border)] pt-[var(--spacing-md)]">
98-
<div className="text-center">
99-
<div className="text-xs leading-tight text-[var(--color-text-muted)]">
100-
{tFooter('copyright')}
101-
</div>
102-
</div>
103-
</div>
93+
<FooterLinkList title={tCommon('resources')} links={resourceLinks} />
94+
<FooterLinkList title={tCommon('documentation')} links={documentationLinks} />
95+
<FooterLinkList title={tCommon('community')} links={communityLinks} />
96+
</div>
97+
98+
<div className="py-[var(--spacing-md)] text-center text-xs text-[var(--color-text-muted)]">
99+
{tFooter('copyright')}
104100
</div>
105101
</footer>
106102
)

src/components/Header.tsx

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
'use client'
22

3+
import { Command } from 'lucide-react'
34
import Image from 'next/image'
45
import { useParams } from 'next/navigation'
56
import { useTranslations } from 'next-intl'
67
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
8+
import { RankingMegaMenu } from '@/components/controls/RankingMegaMenu'
9+
import SearchDialog from '@/components/controls/SearchDialog'
10+
import { StackMegaMenu } from '@/components/controls/StackMegaMenu'
711
import { Link } from '@/i18n/navigation'
8-
import SearchDialog from './controls/SearchDialog'
9-
import { RankingMegaMenu } from './RankingMegaMenu'
10-
import { StackMegaMenu } from './StackMegaMenu'
1112

1213
// Menu item configuration type
1314
interface MenuItem {
1415
href: string
1516
translationKey: string
16-
namespace?: 'header' | 'community'
17+
namespace?: 'header' | 'common'
1718
isExternal?: boolean
1819
hasMegaMenu?: boolean
1920
megaMenuType?: 'aiCodingStack' | 'ranking'
@@ -31,8 +32,7 @@ function Header() {
3132
const [isMenuOpen, setIsMenuOpen] = useState(false)
3233
const [activeMegaMenu, setActiveMegaMenu] = useState<'aiCodingStack' | 'ranking' | null>(null)
3334
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false)
34-
const tHeader = useTranslations('header')
35-
const tCommunity = useTranslations('community')
35+
const t = useTranslations('components.header')
3636

3737
// Menu items configuration - memoized to avoid recreation on each render
3838
const menuItems = useMemo<MenuItem[]>(
@@ -89,16 +89,6 @@ function Header() {
8989
return () => document.removeEventListener('keydown', handleKeyDown)
9090
}, [])
9191

92-
// Get translation text helper - memoized
93-
const getMenuText = useCallback(
94-
(item: MenuItem) => {
95-
const t = item.namespace === 'community' ? tCommunity : tHeader
96-
const text = t(item.translationKey as never)
97-
return item.isExternal ? `→ ${text}` : text
98-
},
99-
[tHeader, tCommunity]
100-
)
101-
10292
// Render desktop menu item
10393
const renderDesktopMenuItem = useCallback(
10494
(item: MenuItem) => {
@@ -117,7 +107,7 @@ function Header() {
117107
aria-expanded={isActive}
118108
aria-haspopup="true"
119109
>
120-
{getMenuText(item)}
110+
{t(item.translationKey as never)}
121111
</Link>
122112
{item.megaMenuType === 'aiCodingStack' && (
123113
<StackMegaMenu isOpen={isActive} onClose={handleMegaMenuClose} />
@@ -133,17 +123,17 @@ function Header() {
133123
<li key={item.href}>
134124
{item.isExternal ? (
135125
<a href={item.href} target="_blank" rel="noopener" className={DESKTOP_LINK_CLASSES}>
136-
{getMenuText(item)}
126+
{t(item.translationKey as never)}
137127
</a>
138128
) : (
139129
<Link href={item.href} className={DESKTOP_LINK_CLASSES}>
140-
{getMenuText(item)}
130+
{t(item.translationKey as never)}
141131
</Link>
142132
)}
143133
</li>
144134
)
145135
},
146-
[activeMegaMenu, handleMegaMenuOpen, handleMegaMenuClose, getMenuText]
136+
[activeMegaMenu, handleMegaMenuOpen, handleMegaMenuClose, t]
147137
)
148138

149139
// Render mobile menu item
@@ -152,22 +142,22 @@ function Header() {
152142
<li key={item.href}>
153143
{item.isExternal ? (
154144
<a href={item.href} target="_blank" rel="noopener" className={MOBILE_LINK_CLASSES}>
155-
{getMenuText(item)}
145+
{t(item.translationKey as never)}
156146
</a>
157147
) : (
158148
<Link href={item.href} className={MOBILE_LINK_CLASSES} onClick={handleMenuClose}>
159-
{getMenuText(item)}
149+
{t(item.translationKey as never)}
160150
</Link>
161151
)}
162152
</li>
163153
),
164-
[handleMenuClose, getMenuText]
154+
[handleMenuClose, t]
165155
)
166156

167157
// Memoized menu button label
168158
const menuButtonLabel = useMemo(
169-
() => (isMenuOpen ? tHeader('closeMenu') : tHeader('openMenu')),
170-
[isMenuOpen, tHeader]
159+
() => (isMenuOpen ? t('closeMenu') : t('openMenu')),
160+
[isMenuOpen, t]
171161
)
172162

173163
return (
@@ -216,8 +206,11 @@ function Header() {
216206
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
217207
/>
218208
</svg>
219-
<span className="flex-1 text-left">{tHeader('searchPlaceholder')}</span>
220-
<kbd className="px-1.5 py-0.5 text-xs border border-[var(--color-border)]">⌘K</kbd>
209+
<span className="flex-1 text-left">{t('searchPlaceholder')}</span>
210+
<kbd className="flex items-center gap-1 px-1.5 py-0.5 text-xs border border-[var(--color-border)]">
211+
<Command className="w-3 h-3" />
212+
<span>K</span>
213+
</kbd>
221214
</button>
222215
</div>
223216

@@ -228,7 +221,7 @@ function Header() {
228221
type="button"
229222
onClick={() => setIsSearchDialogOpen(true)}
230223
className="p-[var(--spacing-xs)] hover:bg-[var(--color-hover)] transition-colors"
231-
aria-label={tHeader('search')}
224+
aria-label={t('search')}
232225
>
233226
<svg
234227
className="w-5 h-5"
@@ -251,7 +244,7 @@ function Header() {
251244
type="button"
252245
onClick={handleMenuToggle}
253246
className="p-[var(--spacing-xs)] hover:bg-[var(--color-hover)] transition-colors"
254-
aria-label={tHeader('toggleMenu')}
247+
aria-label={t('toggleMenu')}
255248
>
256249
<svg
257250
className="w-6 h-6"

src/components/controls/Breadcrumb.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
'use client'
2+
3+
import { useEffect, useRef } from 'react'
14
import { JsonLd } from '@/components/JsonLd'
25
import { Link } from '@/i18n/navigation'
36
import { SITE_CONFIG } from '@/lib/metadata/config'
@@ -14,6 +17,23 @@ export interface BreadcrumbItem {
1417
* - Sticky behavior is enabled when scrolling using CSS position: sticky.
1518
*/
1619
export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
20+
const sectionRef = useRef<HTMLElement>(null)
21+
22+
// Dynamically adjust sticky position based on header height
23+
useEffect(() => {
24+
const updateStickyPosition = () => {
25+
const header = document.querySelector('header')
26+
if (header && sectionRef.current) {
27+
const headerHeight = header.offsetHeight
28+
sectionRef.current.style.top = `${headerHeight}px`
29+
}
30+
}
31+
32+
updateStickyPosition()
33+
window.addEventListener('resize', updateStickyPosition)
34+
return () => window.removeEventListener('resize', updateStickyPosition)
35+
}, [])
36+
1737
// Normalize href to ensure it starts with '/' (unless it's already an absolute URL)
1838
const normalizeHref = (href: string): string => {
1939
// If it's already an absolute URL or starts with '/', return as is
@@ -52,7 +72,8 @@ export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
5272
<>
5373
<JsonLd data={breadcrumbListSchema} />
5474
<section
55-
className="sticky top-[4rem] z-40 py-[var(--spacing-sm)] bg-[var(--color-hover)] border-b border-[var(--color-border)] shadow-sm"
75+
ref={sectionRef}
76+
className="sticky z-40 py-[var(--spacing-sm)] bg-[var(--color-hover)] border-b border-[var(--color-border)] shadow-sm"
5677
data-breadcrumb
5778
>
5879
<div className="max-w-8xl mx-auto px-[var(--spacing-md)]">

src/components/controls/LanguageSwitcher.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export default function LanguageSwitcher() {
5858
const locale = useLocale() as Locale
5959
const router = useRouter()
6060
const pathname = usePathname()
61-
const t = useTranslations('footer')
61+
const t = useTranslations('components.footer')
6262
const [isOpen, setIsOpen] = useState(false)
6363
const dropdownRef = useRef<HTMLDivElement>(null)
6464

src/components/RankingMegaMenu.tsx renamed to src/components/controls/RankingMegaMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const RankingMegaMenu = memo(function RankingMegaMenu({
1717
isOpen,
1818
onClose,
1919
}: RankingMegaMenuProps) {
20-
const tNav = useTranslations('header')
20+
const tNav = useTranslations('components.header')
2121

2222
if (!isOpen) return null
2323

src/components/controls/SearchDialog.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export default function SearchDialog({ isOpen, onClose, locale }: SearchDialogPr
102102
<Command.Input
103103
value={query}
104104
onValueChange={setQuery}
105-
placeholder={t('header.searchPlaceholder')}
105+
placeholder={t('components.header.searchPlaceholder')}
106106
className="flex-1 bg-transparent text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none text-base"
107107
autoFocus
108108
/>
@@ -117,7 +117,7 @@ export default function SearchDialog({ isOpen, onClose, locale }: SearchDialogPr
117117
<Command.Empty className="py-12 text-center">
118118
{query.trim() === '' ? (
119119
<div className="text-[var(--color-text-muted)] text-sm">
120-
{t('search.placeholder')}
120+
{t('components.search.placeholder')}
121121
</div>
122122
) : (
123123
<div className="text-center">
@@ -136,7 +136,7 @@ export default function SearchDialog({ isOpen, onClose, locale }: SearchDialogPr
136136
/>
137137
</svg>
138138
<p className="text-sm text-[var(--color-text-muted)]">
139-
{t('search.noResultsFor', { query })}
139+
{t('components.search.noResultsFor', { query })}
140140
</p>
141141
</div>
142142
)}
@@ -164,7 +164,7 @@ export default function SearchDialog({ isOpen, onClose, locale }: SearchDialogPr
164164
<div className="flex-shrink-0">
165165
<span className="inline-block px-2 py-0.5 text-xs border border-[var(--color-border)] text-[var(--color-text-muted)]">
166166
{t(
167-
`stacks.${result.category === 'providers' ? 'modelProviders' : result.category}`
167+
`shared.stacks.${result.category === 'providers' ? 'modelProviders' : result.category}`
168168
)}
169169
</span>
170170
</div>
@@ -199,13 +199,13 @@ export default function SearchDialog({ isOpen, onClose, locale }: SearchDialogPr
199199
<kbd className="px-1.5 py-0.5 border border-[var(--color-border)] bg-[var(--color-bg)]">
200200
201201
</kbd>
202-
<span>{t('search.navigate')}</span>
202+
<span>{t('components.search.navigate')}</span>
203203
</div>
204204
<div className="flex items-center gap-1.5">
205205
<kbd className="px-1.5 py-0.5 border border-[var(--color-border)] bg-[var(--color-bg)]">
206206
207207
</kbd>
208-
<span>{t('search.select')}</span>
208+
<span>{t('components.search.select')}</span>
209209
</div>
210210
</div>
211211
)}

src/components/controls/SearchInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default function SearchInput({
2727
const dropdownRef = useRef<HTMLDivElement>(null)
2828
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
2929

30-
const placeholderText = placeholder || t('header.searchPlaceholder')
30+
const placeholderText = placeholder || t('components.header.searchPlaceholder')
3131

3232
// Debounce search suggestions
3333
useEffect(() => {
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ const MenuColumn = memo(function MenuColumn({
8181
})
8282

8383
export const StackMegaMenu = memo(function StackMegaMenu({ isOpen, onClose }: StackMegaMenuProps) {
84-
const tStacks = useTranslations('stacks')
85-
const tNav = useTranslations('header')
84+
const tStacks = useTranslations('shared.stacks')
85+
const tNav = useTranslations('components.header')
8686

8787
// Memoize menu sections to avoid recreating on every render
8888
const menuSections = useMemo(

0 commit comments

Comments
 (0)