refactor: migrate App Router pages to locale segments

- Move all App Router pages under [locale] dynamic segment
- Update layout to handle locale params and server-side version fetch
- Remove duplicate pages from root app directory
- Add generateStaticParams for static generation of locale routes
- Update Header component for locale-aware navigation
- Update about page to use next-intl hooks

🤖 Generated with Claude Code
https://claude.ai/code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-09-02 20:19:47 -07:00
parent b7bf44498b
commit 727549db6b
21 changed files with 154 additions and 102 deletions

View file

@ -1,8 +1,8 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next' import { useTranslations } from 'next-intl'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from '~/i18n/navigation'
import InfiniteScroll from 'react-infinite-scroll-component' import InfiniteScroll from 'react-infinite-scroll-component'
// Components // Components
@ -60,7 +60,7 @@ const ProfilePageClient: React.FC<Props> = ({
initialRaid, initialRaid,
initialRecency initialRecency
}) => { }) => {
const { t } = useTranslation('common') const t = useTranslations('common')
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
@ -234,4 +234,4 @@ const ProfilePageClient: React.FC<Props> = ({
) )
} }
export default ProfilePageClient export default ProfilePageClient

80
app/[locale]/layout.tsx Normal file
View file

@ -0,0 +1,80 @@
import { Metadata, Viewport } from 'next'
import localFont from 'next/font/local'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { Viewport as ToastViewport } from '@radix-ui/react-toast'
import { locales } from '../../i18n.config'
import '../../styles/globals.scss'
// Components
import Providers from '../components/Providers'
import Header from '../components/Header'
import UpdateToastClient from '../components/UpdateToastClient'
import VersionHydrator from '../components/VersionHydrator'
// Generate static params for all locales
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
// Metadata
export const metadata: Metadata = {
title: 'granblue.team',
description: 'Create, save, and share Granblue Fantasy party compositions',
}
// Viewport configuration (Next.js 13+ requires separate export)
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
}
// Font
const goalking = localFont({
src: '../../pages/fonts/gk-variable.woff2',
fallback: ['system-ui', 'inter', 'helvetica neue', 'sans-serif'],
variable: '--font-goalking',
})
export default async function LocaleLayout({
children,
params: { locale }
}: {
children: React.ReactNode
params: { locale: string }
}) {
// Load messages for the locale
const messages = await getMessages()
// Fetch version data on the server
let version = null
try {
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:1234'
const res = await fetch(`${baseUrl}/api/version`, {
cache: 'no-store'
})
if (res.ok) {
version = await res.json()
}
} catch (error) {
console.error('Failed to fetch version data:', error)
}
return (
<html lang={locale} className={goalking.variable}>
<body className={goalking.className}>
<NextIntlClientProvider messages={messages}>
<Providers>
<Header />
<VersionHydrator version={version} />
<UpdateToastClient initialVersion={version} />
<main>{children}</main>
<ToastViewport className="ToastViewport" />
</Providers>
</NextIntlClientProvider>
</body>
</html>
)
}

View file

@ -1,8 +1,8 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next' import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation' import { useRouter } from '~/i18n/navigation'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
// Components // Components
@ -24,7 +24,7 @@ const NewPartyClient: React.FC<Props> = ({
raidGroups, raidGroups,
error = false error = false
}) => { }) => {
const { t } = useTranslation('common') const t = useTranslations('common')
const router = useRouter() const router = useRouter()
// State for tab management // State for tab management
@ -76,4 +76,4 @@ const NewPartyClient: React.FC<Props> = ({
return <PartyWrapper raidGroups={raidGroups} /> return <PartyWrapper raidGroups={raidGroups} />
} }
export default NewPartyClient export default NewPartyClient

View file

@ -1,13 +1,15 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import Link from 'next/link' import { Link } from '~/i18n/navigation'
import { useTranslation } from 'next-i18next' import { getTranslations } from 'next-intl/server'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Page not found / granblue.team', title: 'Page not found / granblue.team',
description: 'The page you were looking for could not be found' description: 'The page you were looking for could not be found'
} }
export default function NotFound() { export default async function NotFound() {
const t = await getTranslations('common')
return ( return (
<div className="error-container"> <div className="error-container">
<div className="error-content"> <div className="error-content">
@ -24,4 +26,4 @@ export default function NotFound() {
</div> </div>
</div> </div>
) )
} }

View file

@ -1,8 +1,8 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next' import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation' import { useRouter } from '~/i18n/navigation'
// Utils // Utils
import { appState } from '~/utils/appState' import { appState } from '~/utils/appState'
@ -20,7 +20,7 @@ interface Props {
const PartyPageClient: React.FC<Props> = ({ party, raidGroups }) => { const PartyPageClient: React.FC<Props> = ({ party, raidGroups }) => {
const router = useRouter() const router = useRouter()
const { t } = useTranslation('common') const t = useTranslations('common')
// State for tab management // State for tab management
const [selectedTab, setSelectedTab] = useState<GridType>(GridType.Weapon) const [selectedTab, setSelectedTab] = useState<GridType>(GridType.Weapon)
@ -71,4 +71,4 @@ const PartyPageClient: React.FC<Props> = ({ party, raidGroups }) => {
) )
} }
export default PartyPageClient export default PartyPageClient

View file

@ -1,8 +1,8 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next' import { useTranslations } from 'next-intl'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from '~/i18n/navigation'
// Components // Components
import FilterBar from '~/components/filters/FilterBar' import FilterBar from '~/components/filters/FilterBar'
@ -43,7 +43,7 @@ const SavedPageClient: React.FC<Props> = ({
initialRecency, initialRecency,
error = false error = false
}) => { }) => {
const { t } = useTranslation('common') const t = useTranslations('common')
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
@ -201,4 +201,4 @@ const SavedPageClient: React.FC<Props> = ({
) )
} }
export default SavedPageClient export default SavedPageClient

View file

@ -1,8 +1,8 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next' import { useTranslations } from 'next-intl'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from '~/i18n/navigation'
import InfiniteScroll from 'react-infinite-scroll-component' import InfiniteScroll from 'react-infinite-scroll-component'
// Hooks // Hooks
@ -55,7 +55,7 @@ const TeamsPageClient: React.FC<Props> = ({
initialRecency, initialRecency,
error = false error = false
}) => { }) => {
const { t } = useTranslation('common') const t = useTranslations('common')
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
@ -245,4 +245,4 @@ const TeamsPageClient: React.FC<Props> = ({
) )
} }
export default TeamsPageClient export default TeamsPageClient

View file

@ -2,11 +2,12 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { deleteCookie } from 'cookies-next' import { deleteCookie } from 'cookies-next'
import { useRouter } from 'next/navigation' import { useRouter } from '~/i18n/navigation'
import { useTranslation } from 'next-i18next' import { useTranslations } from 'next-intl'
import { useLocale } from 'next-intl'
import classNames from 'classnames' import classNames from 'classnames'
import clonedeep from 'lodash.clonedeep' import clonedeep from 'lodash.clonedeep'
import Link from 'next/link' import { Link } from '~/i18n/navigation'
import { accountState, initialAccountState } from '~/utils/accountState' import { accountState, initialAccountState } from '~/utils/accountState'
import { appState, initialAppState } from '~/utils/appState' import { appState, initialAppState } from '~/utils/appState'
@ -37,11 +38,11 @@ import styles from '~/components/Header/index.module.scss'
const Header = () => { const Header = () => {
// Localization // Localization
const { t } = useTranslation('common') const t = useTranslations('common')
const locale = useLocale()
// Router // Router
const router = useRouter() const router = useRouter()
const locale = 'en' // TODO: Update when implementing internationalization with App Router
// State management // State management
const [alertOpen, setAlertOpen] = useState(false) const [alertOpen, setAlertOpen] = useState(false)
@ -402,4 +403,4 @@ const Header = () => {
) )
} }
export default Header export default Header

View file

@ -1,49 +1,8 @@
import { Metadata, Viewport } from 'next' // Minimal root layout - all content is handled in [locale]/layout.tsx
import localFont from 'next/font/local'
import { Viewport as ToastViewport } from '@radix-ui/react-toast'
import '../styles/globals.scss'
// Components
import Providers from './components/Providers'
import Header from './components/Header'
import UpdateToastClient from './components/UpdateToastClient'
// Metadata
export const metadata: Metadata = {
title: 'granblue.team',
description: 'Create, save, and share Granblue Fantasy party compositions',
}
// Viewport configuration (Next.js 13+ requires separate export)
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
}
// Font
const goalking = localFont({
src: '../pages/fonts/gk-variable.woff2',
fallback: ['system-ui', 'inter', 'helvetica neue', 'sans-serif'],
variable: '--font-goalking',
})
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return children
<html lang="en" className={goalking.variable}>
<body className={goalking.className}>
<Providers>
<Header />
<UpdateToastClient />
<main>{children}</main>
<ToastViewport className="ToastViewport" />
</Providers>
</body>
</html>
)
} }

View file

@ -3,6 +3,7 @@ import Head from 'next/head'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next' import { useTranslation } from 'next-i18next'
import { NextIntlClientProvider } from 'next-intl'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations' import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import { AboutTabs } from '~utils/enums' import { AboutTabs } from '~utils/enums'
@ -19,6 +20,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'
interface Props { interface Props {
page?: string page?: string
messages: Record<string, any>
} }
const AboutRoute: React.FC<Props> = (props: Props) => { const AboutRoute: React.FC<Props> = (props: Props) => {
@ -100,35 +102,37 @@ const AboutRoute: React.FC<Props> = (props: Props) => {
return ( return (
<div id="About"> <div id="About">
{pageHead()} {pageHead()}
<section> <NextIntlClientProvider messages={props.messages}>
<SegmentedControl blended={true}> <section>
<Segment <SegmentedControl blended={true}>
groupName="about" <Segment
name="about" groupName="about"
selected={currentTab == AboutTabs.About} name="about"
onClick={handleTabClicked} selected={currentTab == AboutTabs.About}
> onClick={handleTabClicked}
{t('about.segmented_control.about')} >
</Segment> {t('about.segmented_control.about')}
<Segment </Segment>
groupName="about" <Segment
name="updates" groupName="about"
selected={currentTab == AboutTabs.Updates} name="updates"
onClick={handleTabClicked} selected={currentTab == AboutTabs.Updates}
> onClick={handleTabClicked}
{t('about.segmented_control.updates')} >
</Segment> {t('about.segmented_control.updates')}
<Segment </Segment>
groupName="about" <Segment
name="roadmap" groupName="about"
selected={currentTab == AboutTabs.Roadmap} name="roadmap"
onClick={handleTabClicked} selected={currentTab == AboutTabs.Roadmap}
> onClick={handleTabClicked}
{t('about.segmented_control.roadmap')} >
</Segment> {t('about.segmented_control.roadmap')}
</SegmentedControl> </Segment>
{currentSection()} </SegmentedControl>
</section> {currentSection()}
</section>
</NextIntlClientProvider>
</div> </div>
) )
} }
@ -149,6 +153,12 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
props: { props: {
page: req.url?.slice(1), page: req.url?.slice(1),
...(await serverSideTranslations(locale, ['common', 'about', 'updates'])), ...(await serverSideTranslations(locale, ['common', 'about', 'updates'])),
// Provide next-intl messages for child components using next-intl
messages: {
common: (await import(`../public/locales/${locale}/common.json`)).default,
about: (await import(`../public/locales/${locale}/about.json`)).default,
updates: (await import(`../public/locales/${locale}/updates.json`)).default,
},
// Will be passed to the page component as props // Will be passed to the page component as props
}, },
} }