Fix Link component legacy behavior and tab switching
- Remove legacyBehavior prop and nested <a> tags from Link components
- Modernize to Next.js 13+ Link API with className directly on Link
- Convert external links to plain <a> tags (LinkItem, Discord link)
- Remove unnecessary passHref props from Header components
- Fix tab switching by mapping string values to GridType enum
The tab switching issue was caused by trying to parse string values
("characters", "weapons", "summons") as integers when they needed to
be mapped to GridType enum values.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1d9bd18848
commit
2d02f88622
10 changed files with 794 additions and 33 deletions
|
|
@ -34,7 +34,19 @@ const PartyPageClient: React.FC<Props> = ({ party, raidGroups }) => {
|
||||||
|
|
||||||
// Handle tab change
|
// Handle tab change
|
||||||
const handleTabChanged = (value: string) => {
|
const handleTabChanged = (value: string) => {
|
||||||
const tabType = parseInt(value) as GridType
|
let tabType: GridType
|
||||||
|
switch (value) {
|
||||||
|
case 'characters':
|
||||||
|
tabType = GridType.Character
|
||||||
|
break
|
||||||
|
case 'summons':
|
||||||
|
tabType = GridType.Summon
|
||||||
|
break
|
||||||
|
case 'weapons':
|
||||||
|
default:
|
||||||
|
tabType = GridType.Weapon
|
||||||
|
break
|
||||||
|
}
|
||||||
setSelectedTab(tabType)
|
setSelectedTab(tabType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,6 @@ const Header = () => {
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${accountState.account.user.username}` || ''}
|
href={`/${accountState.account.user.username}` || ''}
|
||||||
passHref
|
|
||||||
>
|
>
|
||||||
<span>{t('menu.profile')}</span>
|
<span>{t('menu.profile')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -300,7 +299,6 @@ const Header = () => {
|
||||||
<DropdownMenuItem onClick={closeRightMenu}>
|
<DropdownMenuItem onClick={closeRightMenu}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${accountState.account.user.username}` || ''}
|
href={`/${accountState.account.user.username}` || ''}
|
||||||
passHref
|
|
||||||
>
|
>
|
||||||
<span>{t('menu.profile')}</span>
|
<span>{t('menu.profile')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,6 @@ const Header = () => {
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${accountState.account.user.username}` || ''}
|
href={`/${accountState.account.user.username}` || ''}
|
||||||
passHref
|
|
||||||
>
|
>
|
||||||
<span>{t('menu.profile')}</span>
|
<span>{t('menu.profile')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -296,7 +295,6 @@ const Header = () => {
|
||||||
<DropdownMenuItem onClick={closeRightMenu}>
|
<DropdownMenuItem onClick={closeRightMenu}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${accountState.account.user.username}` || ''}
|
href={`/${accountState.account.user.username}` || ''}
|
||||||
passHref
|
|
||||||
>
|
>
|
||||||
<span>{t('menu.profile')}</span>
|
<span>{t('menu.profile')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { ComponentProps } from 'react'
|
import { ComponentProps } from 'react'
|
||||||
import Link from 'next/link'
|
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import ShareIcon from '~public/icons/Share.svg'
|
import ShareIcon from '~public/icons/Share.svg'
|
||||||
|
|
@ -21,7 +20,6 @@ const LinkItem = ({ icon, title, link, className, ...props }: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<Link legacyBehavior href={link}>
|
|
||||||
<a href={link} target="_blank" rel="noreferrer">
|
<a href={link} target="_blank" rel="noreferrer">
|
||||||
<div className={styles.left}>
|
<div className={styles.left}>
|
||||||
<i className={styles.icon}>{icon}</i>
|
<i className={styles.icon}>{icon}</i>
|
||||||
|
|
@ -29,7 +27,6 @@ const LinkItem = ({ icon, title, link, className, ...props }: Props) => {
|
||||||
</div>
|
</div>
|
||||||
<ShareIcon className={styles.shareIcon} />
|
<ShareIcon className={styles.shareIcon} />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,8 +221,8 @@ const PartyHeader = (props: Props) => {
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link legacyBehavior href={`/${username}`} passHref>
|
<Link href={`/${username}`} className={linkClass}>
|
||||||
<a className={linkClass}>{userBlock(username, picture, element)}</a>
|
{userBlock(username, picture, element)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -231,8 +231,8 @@ const PartyHeader = (props: Props) => {
|
||||||
const linkedRaidBlock = (raid: Raid) => {
|
const linkedRaidBlock = (raid: Raid) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link legacyBehavior href={`/teams?raid=${raid.slug}`} passHref>
|
<Link href={`/teams?raid=${raid.slug}`} className={`Raid ${linkClass}`}>
|
||||||
<a className={`Raid ${linkClass}`}>{raid.name[locale]}</a>
|
{raid.name[locale]}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { appWithTranslation } from 'next-i18next'
|
import { appWithTranslation } from 'next-i18next'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import Link from 'next/link'
|
|
||||||
import localFont from 'next/font/local'
|
import localFont from 'next/font/local'
|
||||||
import { useIsomorphicLayoutEffect } from 'react-use'
|
import { useIsomorphicLayoutEffect } from 'react-use'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
@ -125,7 +124,6 @@ function MyApp({ Component, pageProps }: AppProps) {
|
||||||
<div className="Connect">
|
<div className="Connect">
|
||||||
<p>{t('errors.server_unavailable.discord')}</p>
|
<p>{t('errors.server_unavailable.discord')}</p>
|
||||||
<div className="Discord LinkItem">
|
<div className="Discord LinkItem">
|
||||||
<Link legacyBehavior href="https://discord.gg/qyZ5hGdPC8">
|
|
||||||
<a
|
<a
|
||||||
href="https://discord.gg/qyZ5hGdPC8"
|
href="https://discord.gg/qyZ5hGdPC8"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -137,7 +135,6 @@ function MyApp({ Component, pageProps }: AppProps) {
|
||||||
</div>
|
</div>
|
||||||
<ShareIcon className="ShareIcon" />
|
<ShareIcon className="ShareIcon" />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
104
prd/fix-version-fetching.md
Normal file
104
prd/fix-version-fetching.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# PRD: Fix App Version Fetching with Server Prefetch + Hydrator
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
- The app shows a missing translation key for `common.toasts.update.description.` because `appState.version.update_type` is empty.
|
||||||
|
- Root cause: The app never fetches or populates `appState.version` with backend data.
|
||||||
|
|
||||||
|
## Current State (validated)
|
||||||
|
- Backend API and internal route exist and work: `/app/api/version/route.ts` calls `fetchFromApi('/version')`.
|
||||||
|
- Client util exists but is unused: `utils/fetchLatestVersion.tsx`.
|
||||||
|
- Global state shape: `utils/appState.tsx` initializes `version` as `{ version: '0.0', update_type: '', updated_at: '' }`.
|
||||||
|
- `UpdateToastClient` reads `appState.version` but never triggers a fetch and only checks on mount.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Populate `appState.version` reliably on app load without extra client latency.
|
||||||
|
- Show the update toast exactly when appropriate (within time window and not already seen).
|
||||||
|
- Avoid redundant network requests and race conditions.
|
||||||
|
|
||||||
|
## Solution Overview
|
||||||
|
- Server-prefetch the version in the localized layout (`app/[locale]/layout.tsx`).
|
||||||
|
- Hydrate global state on the client via a tiny `VersionHydrator` client component.
|
||||||
|
- Make `UpdateToastClient` reactive to version changes using Valtio snapshots.
|
||||||
|
- Ensure `update_type` maps to valid i18n keys; add a small fallback mapping.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
1) Server Prefetch in `app/[locale]/layout.tsx`
|
||||||
|
- Fetch version data on the server and pass to the client for hydration.
|
||||||
|
- Use either of:
|
||||||
|
- Direct helper: `const version = await fetchFromApi('/version')` (from `app/lib/api-utils.ts`), or
|
||||||
|
- Next fetch: `const res = await fetch('/api/version', { cache: 'no-store' }); const version = await res.json();`
|
||||||
|
- Do this inside the default exported async layout function.
|
||||||
|
- Handle errors gracefully (wrap in try/catch and set `version = null`).
|
||||||
|
|
||||||
|
2) Add `VersionHydrator` (client)
|
||||||
|
- File: `app/components/VersionHydrator.tsx`
|
||||||
|
- Behavior: On mount and when `version` prop changes, set `appState.version`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- 'use client'
|
||||||
|
- import { useEffect } from 'react'
|
||||||
|
- import { appState } from '~/utils/appState'
|
||||||
|
- export default function VersionHydrator({ version }: { version: AppUpdate | null }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (version && version.updated_at) {
|
||||||
|
appState.version = version
|
||||||
|
}
|
||||||
|
}, [version])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
3) Wire Hydrator in Layout
|
||||||
|
- In `app/[locale]/layout.tsx`, render `<VersionHydrator version={version} />` alongside `<Header />` and `<UpdateToastClient />`.
|
||||||
|
- Order suggestion (not strict): Hydrator before UpdateToastClient.
|
||||||
|
|
||||||
|
4) Make `UpdateToastClient` reactive
|
||||||
|
- Import `useSnapshot` from `valtio` and observe `appState.version`.
|
||||||
|
- Change the `useEffect` dependency to run when `version?.updated_at` becomes available.
|
||||||
|
- This ensures the toast can open even if hydration happens after mount.
|
||||||
|
|
||||||
|
Sketch:
|
||||||
|
- import { useSnapshot } from 'valtio'
|
||||||
|
- const { version } = useSnapshot(appState)
|
||||||
|
- useEffect(() => {
|
||||||
|
if (version && version.updated_at) {
|
||||||
|
const cookie = getToastCookie(version.updated_at)
|
||||||
|
const now = new Date()
|
||||||
|
const updatedAt = new Date(version.updated_at)
|
||||||
|
const validUntil = add(updatedAt, { days: 7 })
|
||||||
|
if (now < validUntil && !cookie.seen) setUpdateToastOpen(true)
|
||||||
|
}
|
||||||
|
}, [version?.updated_at])
|
||||||
|
|
||||||
|
5) Validate i18n key mapping for `update_type`
|
||||||
|
- Current keys: `common.toasts.update.description.content` and `...feature`.
|
||||||
|
- Ensure API returns only these (or map unknown types to a default):
|
||||||
|
- const typeKey = ['content', 'feature'].includes(version.update_type) ? version.update_type : 'content'
|
||||||
|
- appState.version.update_type = typeKey
|
||||||
|
|
||||||
|
6) Optional: Pages Router compatibility (temporary)
|
||||||
|
- If the Pages Router `pages/_app.tsx` still needs server-unavailable behavior, add a small client hydrator there too or remove coupling to `appState.version`.
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
- `app/[locale]/layout.tsx` (server prefetch + render Hydrator)
|
||||||
|
- `app/components/VersionHydrator.tsx` (new)
|
||||||
|
- `app/components/UpdateToastClient.tsx` (valtio snapshot + effect dependency)
|
||||||
|
- Optional: Add a small mapping for `update_type` before setting state
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- With API up: `appState.version` is populated on first render; toast opens if within 7 days and unseen.
|
||||||
|
- With API down: No crash; state remains initial; toast doesn’t open.
|
||||||
|
- Verify both locales; i18n keys resolve correctly for `update_type`.
|
||||||
|
- Confirm no duplicate requests across navigations.
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
- Race between hydrator and toast effect: mitigated by making toast reactive to `version?.updated_at`.
|
||||||
|
- Caching behavior: using `no-store` avoids stale data; can switch to `revalidate` if desired.
|
||||||
|
- API type mismatch: handle unknown `update_type` with a default mapping.
|
||||||
|
|
||||||
|
## Rollout Steps
|
||||||
|
- Implement server prefetch and hydrator.
|
||||||
|
- Update `UpdateToastClient` reactivity.
|
||||||
|
- Ship behind no feature flags (safe, read-only state).
|
||||||
|
- Observe logs and confirm behavior in both locales.
|
||||||
|
|
||||||
454
prd/migrate-i18n-to-next-intl.md
Normal file
454
prd/migrate-i18n-to-next-intl.md
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
# PRD: Migrate i18n from next-i18next to next-intl for App Router
|
||||||
|
|
||||||
|
## Current Status (Sep 2, 2025)
|
||||||
|
**Migration is ~90% complete. App is now running successfully!**
|
||||||
|
|
||||||
|
### What's Working:
|
||||||
|
- ✅ Core configuration complete (next.config.js wrapped with plugin, middleware composed)
|
||||||
|
- ✅ App Router structure migrated to [locale] segments
|
||||||
|
- ✅ ALL 76 component files now have correct `next-intl` imports
|
||||||
|
- ✅ Navigation helper created at `/i18n/navigation.ts`
|
||||||
|
- ✅ All `react-i18next` and `next-i18next` imports have been replaced
|
||||||
|
- ✅ **App compiles and runs without import errors**
|
||||||
|
- ✅ English locale loads successfully
|
||||||
|
|
||||||
|
### What's Pending:
|
||||||
|
- ⚠️ 18 components have `<Trans>` components that need refactoring to `t.rich()`
|
||||||
|
- ⚠️ Most components still use standard Next.js navigation instead of locale-aware navigation
|
||||||
|
- ⚠️ Minor issue: Missing translation key `common.toasts.update.description.`
|
||||||
|
- ⚠️ Japanese locale testing needed
|
||||||
|
|
||||||
|
### Next Steps:
|
||||||
|
1. Test the app to verify it runs without import errors
|
||||||
|
2. Update navigation imports to use locale-aware routing
|
||||||
|
3. Refactor Trans components to use `t.rich()`
|
||||||
|
4. Test locale switching
|
||||||
|
5. Clean up old config files
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
The application is displaying raw translation keys (e.g., `party.segmented_control.characters`) instead of localized strings. This is because:
|
||||||
|
- The codebase uses `next-i18next` which is designed for Pages Router
|
||||||
|
- App Router pages don't have proper i18n provider setup
|
||||||
|
- Client components are trying to use `useTranslation` from `next-i18next` without a provider
|
||||||
|
|
||||||
|
## Solution: Migrate to next-intl
|
||||||
|
We will migrate from `next-i18next` to `next-intl`, adopting idiomatic App Router patterns (Next 13–15): localized route segment, composed middleware (i18n + auth), and locale-aware navigation. We will reuse our existing JSON translation files and remove legacy Next.js i18n config to avoid conflicts.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
```bash
|
||||||
|
npm install next-intl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create i18n Configuration
|
||||||
|
Create `/i18n.config.ts` (single source of truth for locales):
|
||||||
|
```typescript
|
||||||
|
export const locales = ['en', 'ja'] as const
|
||||||
|
export type Locale = (typeof locales)[number]
|
||||||
|
export const defaultLocale: Locale = 'en'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Compose Middleware (i18n + auth)
|
||||||
|
Compose next-intl middleware with our existing auth checks. Keep i18n first, then apply auth to the path without the locale prefix.
|
||||||
|
```typescript
|
||||||
|
// middleware.ts
|
||||||
|
import createMiddleware from 'next-intl/middleware'
|
||||||
|
import {locales, defaultLocale, type Locale} from './i18n.config'
|
||||||
|
import {NextResponse} from 'next/server'
|
||||||
|
import type {NextRequest} from 'next/server'
|
||||||
|
|
||||||
|
const intl = createMiddleware({
|
||||||
|
locales,
|
||||||
|
defaultLocale,
|
||||||
|
localePrefix: 'as-needed' // Show locale in URL when not default
|
||||||
|
})
|
||||||
|
|
||||||
|
const PROTECTED_PATHS = ['/saved', '/profile'] as const
|
||||||
|
const MIXED_AUTH_PATHS = ['/api/parties', '/p/'] as const
|
||||||
|
|
||||||
|
export default function middleware(request: NextRequest) {
|
||||||
|
// Run next-intl first (handles locale detection, redirects, etc.)
|
||||||
|
const intlResponse = intl(request)
|
||||||
|
if (intlResponse) return intlResponse
|
||||||
|
|
||||||
|
const {pathname} = request.nextUrl
|
||||||
|
const seg = pathname.split('/')[1]
|
||||||
|
const pathWithoutLocale = locales.includes(seg as Locale)
|
||||||
|
? pathname.slice(seg.length + 1) || '/'
|
||||||
|
: pathname
|
||||||
|
|
||||||
|
const isProtectedPath = PROTECTED_PATHS.some(
|
||||||
|
(p) => pathWithoutLocale === p || pathWithoutLocale.startsWith(p + '/')
|
||||||
|
)
|
||||||
|
const isMixedAuthPath = MIXED_AUTH_PATHS.some(
|
||||||
|
(p) => pathWithoutLocale === p || pathWithoutLocale.startsWith(p)
|
||||||
|
)
|
||||||
|
|
||||||
|
const needsAuth =
|
||||||
|
isProtectedPath || (isMixedAuthPath && ['POST', 'PUT', 'DELETE'].includes(request.method))
|
||||||
|
|
||||||
|
if (!needsAuth) return NextResponse.next()
|
||||||
|
|
||||||
|
const accountCookie = request.cookies.get('account')
|
||||||
|
if (!accountCookie?.value) {
|
||||||
|
if (pathWithoutLocale.startsWith('/api/')) {
|
||||||
|
return NextResponse.json({error: 'Authentication required'}, {status: 401})
|
||||||
|
}
|
||||||
|
// Preserve locale in redirect
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = '/teams'
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = JSON.parse(accountCookie.value)
|
||||||
|
if (!account.token) {
|
||||||
|
if (pathWithoutLocale.startsWith('/api/')) {
|
||||||
|
return NextResponse.json({error: 'Authentication required'}, {status: 401})
|
||||||
|
}
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = '/teams'
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (pathWithoutLocale.startsWith('/api/')) {
|
||||||
|
return NextResponse.json({error: 'Authentication required'}, {status: 401})
|
||||||
|
}
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = '/teams'
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next|_vercel|.*\\..*).*)']
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Localized Layout (App Router)
|
||||||
|
Create `/app/[locale]/layout.tsx` and place `NextIntlClientProvider` here. Use `unstable_setRequestLocale` to tell Next the active locale and pre-generate locale params if statically building.
|
||||||
|
```typescript
|
||||||
|
// app/[locale]/layout.tsx
|
||||||
|
import {NextIntlClientProvider} from 'next-intl'
|
||||||
|
import {getMessages, unstable_setRequestLocale} from 'next-intl/server'
|
||||||
|
import {locales} from '../../i18n.config'
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.map((locale) => ({locale}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params: {locale}
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
params: {locale: string}
|
||||||
|
}) {
|
||||||
|
unstable_setRequestLocale(locale)
|
||||||
|
const messages = await getMessages()
|
||||||
|
return (
|
||||||
|
<html lang={locale}>
|
||||||
|
<body>
|
||||||
|
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Notes:
|
||||||
|
- If `/app/layout.tsx` exists, keep it minimal (global styles/fonts only) and avoid setting the `lang` attribute or an i18n provider there. The locale-specific layout above should own `<html>`/`<body>`.
|
||||||
|
|
||||||
|
### 5. Locale-Aware Navigation
|
||||||
|
Replace `next/link` and `next/navigation` with `next-intl/navigation` to preserve locale on navigation.
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {useRouter, usePathname, useSearchParams} from 'next/navigation'
|
||||||
|
|
||||||
|
// After
|
||||||
|
import {Link, useRouter, usePathname, useSearchParams} from 'next-intl/navigation'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Update Component Imports
|
||||||
|
Change all translation imports from:
|
||||||
|
```typescript
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import { Trans } from 'next-i18next'
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```typescript
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
// Note: Trans component usage will need to be refactored
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Update Translation Hook Usage
|
||||||
|
Change from:
|
||||||
|
```typescript
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
// Usage: t('party.segmented_control.characters')
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```typescript
|
||||||
|
const t = useTranslations('common')
|
||||||
|
// Usage: t('party.segmented_control.characters')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Update Trans Component Usage
|
||||||
|
The `Trans` component works differently in next-intl. Change from:
|
||||||
|
```typescript
|
||||||
|
<Trans i18nKey="modals.summons.messages.remove">
|
||||||
|
Are you sure you want to remove{' '}
|
||||||
|
<strong>{{ weapon: gridSummon?.object.name[locale] }}</strong> from
|
||||||
|
your team?
|
||||||
|
</Trans>
|
||||||
|
```
|
||||||
|
|
||||||
|
To using rich text formatting:
|
||||||
|
```typescript
|
||||||
|
t.rich('modals.summons.messages.remove', {
|
||||||
|
weapon: gridSummon?.object.name[locale],
|
||||||
|
strong: (chunks) => <strong>{chunks}</strong>
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Language Switch
|
||||||
|
Prefer path-based locale switching over cookie+refresh for clarity and correct URL behavior.
|
||||||
|
```typescript
|
||||||
|
// Using next-intl/navigation
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
function changeLanguage(to: 'en'|'ja') {
|
||||||
|
router.replace(pathname, {locale: to})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
You may still set `NEXT_LOCALE` for persistence, but the router option should be the source of truth.
|
||||||
|
|
||||||
|
### 10. Server Helpers and Metadata
|
||||||
|
Use server utilities for server-only files and localized metadata.
|
||||||
|
```typescript
|
||||||
|
// In server components/routes (e.g., not-found.tsx)
|
||||||
|
import {getTranslations} from 'next-intl/server'
|
||||||
|
const t = await getTranslations('common')
|
||||||
|
|
||||||
|
// Localized metadata
|
||||||
|
export async function generateMetadata({params: {locale}}) {
|
||||||
|
const t = await getTranslations({locale, namespace: 'common'})
|
||||||
|
return {title: t('title')}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
|
||||||
|
### Core Configuration Files
|
||||||
|
1. `/package.json` - Add `next-intl` dependency
|
||||||
|
2. `/i18n.config.ts` - Create new configuration file
|
||||||
|
3. `/middleware.ts` - Compose next-intl + auth middleware
|
||||||
|
4. `/i18n/request.ts` - Ensure imports from `../i18n.config` are correct
|
||||||
|
5. `/next.config.js` - Remove `i18n` property (avoid conflict with middleware)
|
||||||
|
6. `/next-i18next.config.js` - Remove after migration
|
||||||
|
|
||||||
|
### App Router Files (7 files)
|
||||||
|
1. `/app/[locale]/layout.tsx` - New; add NextIntlClientProvider
|
||||||
|
2. `/app/new/NewPartyClient.tsx` - Update imports and usage
|
||||||
|
3. `/app/[username]/ProfilePageClient.tsx` - Update imports and usage
|
||||||
|
4. `/app/saved/SavedPageClient.tsx` - Update imports and usage
|
||||||
|
5. `/app/p/[party]/PartyPageClient.tsx` - Update imports and usage
|
||||||
|
6. `/app/teams/TeamsPageClient.tsx` - Update imports and usage
|
||||||
|
7. `/app/not-found.tsx` - Update imports and usage
|
||||||
|
8. `/app/layout.tsx` - If present, keep minimal; remove i18n/lang handling
|
||||||
|
|
||||||
|
### Component Files (76 files)
|
||||||
|
All files in `/components` directory that use translations and/or navigation:
|
||||||
|
1. `/components/Header/index.tsx`
|
||||||
|
2. `/components/ErrorSection/index.tsx`
|
||||||
|
3. `/components/party/Party/index.tsx`
|
||||||
|
4. `/components/party/PartyHeader/index.tsx`
|
||||||
|
5. `/components/party/PartyFooter/index.tsx`
|
||||||
|
6. `/components/party/PartySegmentedControl/index.tsx`
|
||||||
|
7. `/components/party/PartyDropdown/index.tsx`
|
||||||
|
8. `/components/party/EditPartyModal/index.tsx`
|
||||||
|
9. `/components/party/PartyVisibilityDialog/index.tsx`
|
||||||
|
10. `/components/character/CharacterUnit/index.tsx`
|
||||||
|
11. `/components/character/CharacterGrid/index.tsx`
|
||||||
|
12. `/components/character/CharacterModal/index.tsx`
|
||||||
|
13. `/components/character/CharacterHovercard/index.tsx`
|
||||||
|
14. `/components/character/CharacterConflictModal/index.tsx`
|
||||||
|
15. `/components/character/CharacterSearchFilterBar/index.tsx`
|
||||||
|
16. `/components/weapon/WeaponUnit/index.tsx`
|
||||||
|
17. `/components/weapon/WeaponGrid/index.tsx`
|
||||||
|
18. `/components/weapon/WeaponModal/index.tsx`
|
||||||
|
19. `/components/weapon/WeaponHovercard/index.tsx`
|
||||||
|
20. `/components/weapon/WeaponConflictModal/index.tsx`
|
||||||
|
21. `/components/weapon/WeaponKeySelect/index.tsx`
|
||||||
|
22. `/components/weapon/WeaponSearchFilterBar/index.tsx`
|
||||||
|
23. `/components/summon/SummonUnit/index.tsx`
|
||||||
|
24. `/components/summon/SummonGrid/index.tsx`
|
||||||
|
25. `/components/summon/SummonHovercard/index.tsx`
|
||||||
|
26. `/components/summon/SummonSearchFilterBar/index.tsx`
|
||||||
|
27. `/components/job/JobSection/index.tsx`
|
||||||
|
28. `/components/job/JobDropdown/index.tsx`
|
||||||
|
29. `/components/job/JobSkillItem/index.tsx`
|
||||||
|
30. `/components/job/JobAccessoryPopover/index.tsx`
|
||||||
|
31. `/components/job/JobSkillSearchFilterBar/index.tsx`
|
||||||
|
32. `/components/auth/LoginModal/index.tsx`
|
||||||
|
33. `/components/auth/SignupModal/index.tsx`
|
||||||
|
34. `/components/auth/AccountModal/index.tsx`
|
||||||
|
35. `/components/raids/RaidCombobox/index.tsx`
|
||||||
|
36. `/components/raids/RaidItem/index.tsx`
|
||||||
|
37. `/components/search/SearchModal/index.tsx`
|
||||||
|
38. `/components/filters/FilterBar/index.tsx`
|
||||||
|
39. `/components/filters/FilterModal/index.tsx`
|
||||||
|
40. `/components/mastery/AwakeningSelectWithInput/index.tsx`
|
||||||
|
41. `/components/mastery/AxSelect/index.tsx`
|
||||||
|
42. `/components/mastery/ExtendedMasterySelect/index.tsx`
|
||||||
|
43. `/components/uncap/UncapIndicator/index.tsx`
|
||||||
|
44. `/components/uncap/TranscendencePopover/index.tsx`
|
||||||
|
45. `/components/uncap/TranscendenceStar/index.tsx`
|
||||||
|
46. `/components/uncap/TranscendenceFragment/index.tsx`
|
||||||
|
47. `/components/extra/GuidebookUnit/index.tsx`
|
||||||
|
48. `/components/extra/GuidebooksGrid/index.tsx`
|
||||||
|
49. `/components/extra/ExtraWeaponsGrid/index.tsx`
|
||||||
|
50. `/components/extra/ExtraSummonsGrid/index.tsx`
|
||||||
|
51. `/components/reps/CharacterRep/index.tsx`
|
||||||
|
52. `/components/reps/GridRep/index.tsx`
|
||||||
|
53. `/components/toasts/UpdateToast/index.tsx`
|
||||||
|
54. `/components/toasts/UrlCopiedToast/index.tsx`
|
||||||
|
55. `/components/toasts/RemixedToast/index.tsx`
|
||||||
|
56. `/components/dialogs/RemixTeamAlert/index.tsx`
|
||||||
|
57. `/components/dialogs/DeleteTeamAlert/index.tsx`
|
||||||
|
58. `/components/common/Editor/index.tsx`
|
||||||
|
59. `/components/common/SelectWithInput/index.tsx`
|
||||||
|
60. `/components/common/ToolbarButton/index.tsx`
|
||||||
|
61. `/components/common/MentionTypeahead/index.tsx`
|
||||||
|
62. `/components/ElementToggle/index.tsx`
|
||||||
|
63. `/components/MentionList/index.tsx`
|
||||||
|
64. `/components/about/AboutPage/index.tsx`
|
||||||
|
65. `/components/about/AboutHead/index.tsx`
|
||||||
|
66. `/components/about/RoadmapPage/index.tsx`
|
||||||
|
67. `/components/about/UpdatesPage/index.tsx`
|
||||||
|
68. `/components/about/ContentUpdate/index.tsx`
|
||||||
|
69. `/components/about/updates/ContentUpdate2022/index.tsx`
|
||||||
|
70. `/components/about/updates/ContentUpdate2023/index.tsx`
|
||||||
|
71. `/components/about/updates/ContentUpdate2024/index.tsx`
|
||||||
|
72. `/components/head/NewHead/index.tsx`
|
||||||
|
73. `/components/head/ProfileHead/index.tsx`
|
||||||
|
74. `/components/head/SavedHead/index.tsx`
|
||||||
|
75. `/components/head/TeamsHead/index.tsx`
|
||||||
|
76. `/components/party/PartyHead/index.tsx`
|
||||||
|
|
||||||
|
### Pages Router Files (temporary)
|
||||||
|
Plan to remove Pages Router usage. Temporarily keep while migrating:
|
||||||
|
1. `/pages/_app.tsx` - Keep until all pages are on App Router
|
||||||
|
2. `/pages/about.tsx` - Keep until migrated
|
||||||
|
|
||||||
|
## Task List
|
||||||
|
|
||||||
|
- [x] **Setup and Configuration**
|
||||||
|
- [x] Install next-intl package (v4.3.5)
|
||||||
|
- [x] Create `/i18n.config.ts` with locale configuration
|
||||||
|
- [x] Create/update `/middleware.ts` for locale routing (composed with auth)
|
||||||
|
- [x] Update `/next.config.js` with `createNextIntlPlugin` wrapper
|
||||||
|
- [x] Create `/i18n/navigation.ts` with `createNavigation` for locale-aware routing
|
||||||
|
|
||||||
|
- [x] **Localized Layout**
|
||||||
|
- [x] Create `/app/[locale]/layout.tsx` with `unstable_setRequestLocale`
|
||||||
|
- [x] Load messages with `getMessages()` and wrap with `NextIntlClientProvider`
|
||||||
|
- [x] Keep `/app/layout.tsx` minimal (no lang/i18n)
|
||||||
|
|
||||||
|
- [x] **Update App Router Pages (7+ files)**
|
||||||
|
- [x] Update `/app/new/NewPartyClient.tsx`
|
||||||
|
- [x] Update `/app/[username]/ProfilePageClient.tsx`
|
||||||
|
- [x] Update `/app/saved/SavedPageClient.tsx`
|
||||||
|
- [x] Update `/app/p/[party]/PartyPageClient.tsx`
|
||||||
|
- [x] Update `/app/teams/TeamsPageClient.tsx`
|
||||||
|
- [x] Update `/app/not-found.tsx`
|
||||||
|
- [x] Update `/app/components/Header.tsx`
|
||||||
|
|
||||||
|
- [ ] **Update Component Imports (76 files)**
|
||||||
|
- [x] Partial: 54/76 files updated to use `next-intl`
|
||||||
|
- [x] Fix remaining 8 files with `react-i18next` imports
|
||||||
|
- [x] Fix remaining 11 files with `next-i18next` imports
|
||||||
|
- [ ] Replace `next/link` and `next/navigation` with locale-aware navigation (only 1 file done)
|
||||||
|
- [ ] Refactor 18 files still using `Trans` component to `t.rich()`
|
||||||
|
|
||||||
|
- [x] **Testing and Verification**
|
||||||
|
- [x] Test English locale - App loads successfully!
|
||||||
|
- [ ] Test Japanese locale
|
||||||
|
- [ ] Verify locale switching works
|
||||||
|
- [ ] Check all translation keys render correctly
|
||||||
|
- [ ] Test dynamic translations with variables
|
||||||
|
|
||||||
|
- [ ] **Cleanup**
|
||||||
|
- [ ] Remove `i18n` from `next.config.js`
|
||||||
|
- [ ] Remove `/next-i18next.config.js`
|
||||||
|
- [ ] Remove `next-i18next` from `package.json`
|
||||||
|
- [ ] Update documentation
|
||||||
|
|
||||||
|
## Files Still Requiring Updates
|
||||||
|
|
||||||
|
### Files with `react-i18next` imports (8 files): ✅ COMPLETED
|
||||||
|
1. ✅ `/components/auth/LoginModal/index.tsx` - Fixed import
|
||||||
|
2. ✅ `/components/filters/FilterModal/index.tsx` - Fixed import, commented Trans
|
||||||
|
3. ✅ `/components/job/JobSkillSearchFilterBar/index.tsx` - Fixed import
|
||||||
|
4. ✅ `/components/party/EditPartyModal/index.tsx` - Fixed import, simplified Trans
|
||||||
|
5. ✅ `/components/party/PartyVisibilityDialog/index.tsx` - Fixed import
|
||||||
|
6. ✅ `/components/raids/RaidCombobox/index.tsx` - Fixed import
|
||||||
|
7. ✅ `/components/search/SearchModal/index.tsx` - Fixed import
|
||||||
|
8. ✅ `/components/weapon/WeaponConflictModal/index.tsx` - Fixed import, simplified Trans
|
||||||
|
|
||||||
|
### Files with `next-i18next` imports (11 files): ✅ COMPLETED
|
||||||
|
1. ✅ `/components/about/AboutPage/index.tsx` - Fixed import, simplified Trans
|
||||||
|
2. ✅ `/components/character/CharacterConflictModal/index.tsx` - Fixed import
|
||||||
|
3. ✅ `/components/character/CharacterModal/index.tsx` - Fixed import
|
||||||
|
4. ✅ `/components/character/CharacterUnit/index.tsx` - Fixed import
|
||||||
|
5. ✅ `/components/dialogs/RemixTeamAlert/index.tsx` - Fixed import
|
||||||
|
6. ✅ `/components/extra/GuidebookUnit/index.tsx` - Fixed import
|
||||||
|
7. ✅ `/components/job/JobSkillItem/index.tsx` - Fixed import
|
||||||
|
8. ✅ `/components/summon/SummonUnit/index.tsx` - Fixed import
|
||||||
|
9. ✅ `/components/toasts/RemixedToast/index.tsx` - Fixed import
|
||||||
|
10. ✅ `/components/weapon/WeaponModal/index.tsx` - Fixed import
|
||||||
|
11. ✅ `/components/weapon/WeaponUnit/index.tsx` - Fixed import
|
||||||
|
|
||||||
|
### Additional files with Trans components but correct imports (3 files):
|
||||||
|
1. `/components/auth/SignupModal/index.tsx` - Imports correct but Trans is commented out
|
||||||
|
2. `/components/uncap/TranscendencePopover/index.tsx`
|
||||||
|
3. `/components/uncap/TranscendenceStar/index.tsx`
|
||||||
|
4. `/components/uncap/UncapIndicator/index.tsx`
|
||||||
|
|
||||||
|
### Navigation Updates Required:
|
||||||
|
- Most components still use `next/navigation` instead of `~/i18n/navigation`
|
||||||
|
- Only 1 component currently uses the locale-aware navigation
|
||||||
|
- The navigation helper exists at `/i18n/navigation.ts` using `createNavigation`
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
1. All translation keys render as localized strings (not raw keys)
|
||||||
|
2. Locale switching between English and Japanese works and preserves locale in URLs
|
||||||
|
3. Dynamic translations with variables work correctly
|
||||||
|
4. No console errors related to i18n
|
||||||
|
5. App Router pages work correctly; Pages Router pages are removed or temporarily functional during migration
|
||||||
|
|
||||||
|
## Risks and Mitigation
|
||||||
|
- **Risk**: Middleware composition can cause routing conflicts
|
||||||
|
- **Mitigation**: Run next-intl middleware first; strip locale before auth checks; unify matcher
|
||||||
|
|
||||||
|
- **Risk**: Breaking Pages Router pages during transition
|
||||||
|
- **Mitigation**: Keep Pages Router temporarily; schedule removal after App Router parity
|
||||||
|
|
||||||
|
- **Risk**: Trans component refactoring may be complex
|
||||||
|
- **Mitigation**: Start with simple replacements; handle rich content with `t.rich()` gradually
|
||||||
|
|
||||||
|
- **Risk**: Large number of files to update
|
||||||
|
- **Mitigation**: Batch replace imports; test incrementally; prioritize high-traffic routes
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The existing translation files in `/public/locales/` can be reused without changes
|
||||||
|
- The `i18n/request.ts` file already uses next-intl; ensure it imports from the root `i18n.config.ts`
|
||||||
|
- Use `next-intl/navigation` everywhere for links and routers to preserve locale
|
||||||
|
- Remove `i18n` from `next.config.js` to avoid conflicts with next-intl middleware routing
|
||||||
|
- Consider using a codemod/script to automate import updates across 76+ files
|
||||||
147
prd/migrate-next-to-sveltekit-runes.md
Normal file
147
prd/migrate-next-to-sveltekit-runes.md
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# PRD: Migrate Web App from Next.js to SvelteKit (Svelte 5 + Runes)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The app is a small but interactive site for building, saving, and sharing Granblue Fantasy team compositions. It currently runs on Next.js 14 using a hybrid of App Router and legacy Pages, with `next-intl` for i18n, Valtio for state, Radix-based UI wrappers, and Tiptap for rich text. The backend is a Rails API and will not change.
|
||||||
|
|
||||||
|
## Problem / Opportunity
|
||||||
|
- The UI layer carries Next-specific complexity (hybrid routing, middleware composition, Valtio, React wrappers around primitives) for a scope that aligns well with SvelteKit’s simpler mental model.
|
||||||
|
- Svelte 5 (Runes) and SvelteKit provide small, fast bundles and ergonomic state management for highly interactive UIs like the party editor.
|
||||||
|
- A rewrite would impose upfront cost but could yield better performance, maintainability, and developer experience.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Achieve feature and URL parity (including localized routing and auth/method guards) with the current Next.js app.
|
||||||
|
- Reuse domain types, data contracts, and i18n content; minimize churn to the Rails API layer.
|
||||||
|
- Maintain or improve perceived performance, accessibility, and UX of primitives (menus, dialogs, tooltips, toasts).
|
||||||
|
|
||||||
|
## Non‑Goals
|
||||||
|
- No changes to the Rails API endpoints, auth semantics, or data shape.
|
||||||
|
- No new product features beyond parity (performance and a11y improvements are in scope).
|
||||||
|
|
||||||
|
## Current State (Summary)
|
||||||
|
- Framework: Next.js 14 with `app/` and `pages/` side by side.
|
||||||
|
- Routing/i18n: `app/[locale]/…` with `next-intl` and “as-needed” default-locale prefixing; middleware composes i18n with auth/method guards.
|
||||||
|
- State: Valtio global stores for `accountState` and `appState` (party/grid/editor flows).
|
||||||
|
- Data: axios-based `utils/api.tsx`, app route handlers under `app/api/**` proxy to Rails; server helpers in `app/lib/*` with zod validation.
|
||||||
|
- UI: Components under `components/**` using SCSS modules, Radix-based wrappers, custom editor based on Tiptap React + custom extensions.
|
||||||
|
- URL State: `nuqs` binds filters to query params.
|
||||||
|
- Storybook: React/Next preset.
|
||||||
|
|
||||||
|
## Feature/Parity Requirements
|
||||||
|
- Public routes: `/`, `/new`, `/teams`, `/p/[party]` (+ tab segments), `/[username]`, `/about`, `/updates`, `/roadmap` with locale variants under `/ja` and default-locale “as-needed” behavior.
|
||||||
|
- Auth/method gates: protect `/saved` and `/profile`; 401 JSON for unauthorized API mutations; preserve method-based rules under `/api/**`.
|
||||||
|
- Party editor: weapons/summons/characters, job/skills/accessory, guidebooks, toggles, remix/delete, editability rules by cookie/user.
|
||||||
|
- Search & filters: team discovery with element/raid/recency, advanced filters from cookie, pagination (append/replace).
|
||||||
|
- i18n: reuse JSON namespaces; preserve current translation keys and locale cookie behavior.
|
||||||
|
- Primitives: dropdown menu, dialog, tooltip, toast, popover, select, switch, slider, command, segmented control with keyboard a11y.
|
||||||
|
- Editor: Tiptap with mentions, link, lists, headings, youtube, highlight, placeholder.
|
||||||
|
|
||||||
|
## Proposed Direction
|
||||||
|
Rebuild the web app on SvelteKit 2 and Svelte 5 Runes. Keep the Rails API contract intact. Adopt Svelte stores for global state and Runes for local component state. Replace React-specific libraries with Svelte-native equivalents while reusing JSON messages, type declarations, API contracts, and Tiptap core extensions.
|
||||||
|
|
||||||
|
## Target Architecture (SvelteKit)
|
||||||
|
- Routing: `src/routes/[lang=locale]/…` to mirror localized segments; top-level `/` redirects to `/new`.
|
||||||
|
- Hooks: `hooks.server.ts` handles locale detection/redirects and reproduces auth/method gates; `handleFetch` attaches bearer tokens from cookies.
|
||||||
|
- Data loading: `+layout.server.ts`/`+page.server.ts` for SSR (e.g., version, lists); `+page.ts` for client/SSR-unified fetches.
|
||||||
|
- Endpoints: `src/routes/api/**/+server.ts` proxy to Rails with zod validation and consistent status codes.
|
||||||
|
- State: Svelte `writable` stores for `account` and `app` (party, grid, search, jobs, skills, version), with typed helper actions; local UI state via runes.
|
||||||
|
- Theming: small store plus SSR-safe initial theme; apply class/data-theme on `<html>`.
|
||||||
|
- i18n: `svelte-i18n` (recommended) loading existing JSON bundles; locale cookie + accept-language fallback; “as-needed” default-locale paths.
|
||||||
|
|
||||||
|
## Dependency Mapping (Equivalents)
|
||||||
|
- Framework/Core:
|
||||||
|
- `next` → `@sveltejs/kit`
|
||||||
|
- `react`, `react-dom` → `svelte@5`
|
||||||
|
- `@svgr/webpack` → `vite-svg-loader` (or inline SVG)
|
||||||
|
- Routing/URL State:
|
||||||
|
- `next/navigation` → SvelteKit `goto`, `$page`, `afterNavigate`
|
||||||
|
- `nuqs` → `$page.url.searchParams` + small helper or `sveltekit-search-params`
|
||||||
|
- Data/Query:
|
||||||
|
- `@tanstack/react-query` → `@tanstack/svelte-query@5`
|
||||||
|
- `axios` → keep or migrate to `fetch`; keep zod
|
||||||
|
- i18n:
|
||||||
|
- `next-intl` → `svelte-i18n` (reuse JSON); remove `next-i18next`
|
||||||
|
- `i18next*` libs → only if choosing an i18next-based Svelte binding (otherwise remove)
|
||||||
|
- UI/A11y:
|
||||||
|
- `@radix-ui/*` → `bits-ui` primitives and/or `shadcn-svelte`
|
||||||
|
- `tippy.js` → `svelte-tippy` (or small custom)
|
||||||
|
- `cmdk` → `cmdk-svelte`/`cmdk-sv`
|
||||||
|
- Theming:
|
||||||
|
- `next-themes` → small Svelte store + cookie bootstrap
|
||||||
|
- Editor:
|
||||||
|
- `@tiptap/react` → Tiptap core with Svelte integration (e.g., `svelte-tiptap`); reuse custom extensions
|
||||||
|
- Typeahead/Select/Infinite:
|
||||||
|
- `react-bootstrap-typeahead` → `svelte-select`
|
||||||
|
- `react-infinite-scroll-component` → `svelte-infinite-loading` or IO-based action
|
||||||
|
- Misc:
|
||||||
|
- `cookies-next` → `event.cookies` server-side; `js-cookie` client
|
||||||
|
- `react-use`/`usehooks-ts` → `svelte-use`
|
||||||
|
- `remixicon-react` → `svelte-remixicon` or `unplugin-icons` (`ri:*`)
|
||||||
|
- `react-linkify` → `linkifyjs`/`linkify-html`
|
||||||
|
- SCSS → `svelte-preprocess` with component-scoped styles and global imports
|
||||||
|
- Storybook: `@storybook/nextjs` → `@storybook/sveltekit`
|
||||||
|
|
||||||
|
## Reuse vs. Rewrite
|
||||||
|
- Reuse
|
||||||
|
- i18n bundles under `public/locales/{en,ja}`
|
||||||
|
- Domain types `types/*.d.ts` (move to `src/lib/types`)
|
||||||
|
- Pure utilities in `utils/*` (parsers, enums, formatters)
|
||||||
|
- API contracts and zod schemas (`app/lib/api-utils.ts`)
|
||||||
|
- Tiptap custom extensions (`extensions/CustomMention`, `CustomSuggestion`)
|
||||||
|
- Public assets and SVGs
|
||||||
|
- Rewrite
|
||||||
|
- All React components and Valtio stores as Svelte components/stores
|
||||||
|
- Next middleware as `hooks.server.ts` (auth + locale); API handlers as `+server.ts`
|
||||||
|
- URL/query state helpers (replace `nuqs`)
|
||||||
|
- Theming (replace `next-themes`)
|
||||||
|
- Storybook stories
|
||||||
|
- Font handling (move `pages/fonts/gk-variable.woff2` to `static/` and add `@font-face`)
|
||||||
|
|
||||||
|
## Routing & i18n Details
|
||||||
|
- Localized route group `[lang=locale]` constrained to `en|ja` (from existing `i18n.config.ts`).
|
||||||
|
- “As-needed” default-locale behavior via redirects in `handle` and locale-aware links.
|
||||||
|
- Preserve existing route structure and query semantics (e.g., `/teams?element=…&raid=…`).
|
||||||
|
|
||||||
|
## Auth & Cookies
|
||||||
|
- Mirror `middleware.ts` logic: protect `/saved` and `/profile`; method-guard mixed routes; return 401 JSON for API.
|
||||||
|
- Server: use `event.cookies` and set `locals.user`; client: `js-cookie` where necessary.
|
||||||
|
- Use `handleFetch` to inject `Authorization` header from `account` cookie token.
|
||||||
|
|
||||||
|
## State & Data
|
||||||
|
- Global state: writable stores mirroring `initialAccountState` and `initialAppState` with typed actions; local UI state via runes.
|
||||||
|
- Queries: `@tanstack/svelte-query` for client/SSR-safe caching; SSR data via `+page.server.ts` where beneficial.
|
||||||
|
- Invalidation: use SvelteKit `invalidate`/`invalidateAll` in place of Next `revalidatePath`.
|
||||||
|
|
||||||
|
## UI & Editor
|
||||||
|
- Primitives: build on Bits-UI/shadcn-svelte; verify keyboard navigation and focus management; carry over SCSS look-and-feel.
|
||||||
|
- Editor: use Tiptap core with Svelte wrapper; reuse mention/suggestion logic and toolbar feature set (bold/italic/strike/heading/list/link/youtube/highlight/placeholder).
|
||||||
|
|
||||||
|
## Performance & SEO
|
||||||
|
- Expect smaller bundles and faster hydration from Svelte runtime.
|
||||||
|
- Maintain SSR for primary pages and content; keep linkable, crawlable party pages and listings.
|
||||||
|
- Optimize images/SVGs via Vite pipeline; keep existing public assets.
|
||||||
|
|
||||||
|
## Tradeoffs (Pros / Cons)
|
||||||
|
- Pros: performance, simpler state and data flows, clearer SSR/CSR boundaries, component‑scoped styling, fast DX.
|
||||||
|
- Cons: full component rewrite, parity tuning for UI primitives, community-maintained editor bindings, team ramp‑up, Storybook conversion.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
- Stay on Next.js and complete the ongoing App Router + next‑intl consolidation (lowest risk and cost).
|
||||||
|
- Prototype SvelteKit for the party editor only (dual‑stack) and evaluate before committing.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- Keep proxy endpoints under `/api/**` or talk to Rails directly with CORS? (Proxy simplifies auth and error shaping.)
|
||||||
|
- UI library choice finalization: Bits‑UI vs shadcn‑svelte (or a minimal custom layer where needed).
|
||||||
|
- Icon strategy: `unplugin-icons` vs direct SVG imports.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Route and locale parity with auth/method guards and “as‑needed” default‑locale behavior.
|
||||||
|
- Party editor parity (create/update/remix/delete; grids; jobs/skills/accessories; guidebooks; toggles).
|
||||||
|
- Query‑linked filters and pagination on `/teams`, `/saved`, `/[username]`.
|
||||||
|
- Editor parity (mentions, link, lists, headings, youtube, highlight, placeholder) and equivalent styling.
|
||||||
|
- Primitives parity with keyboard a11y and visual consistency.
|
||||||
|
- i18n keys resolve from existing JSON bundles; theme persists SSR + client.
|
||||||
|
|
||||||
|
## Success Measures
|
||||||
|
- Time‑to‑interactive and bundle size improved vs. Next build.
|
||||||
|
- No regression in error rates for API interactions (remix/delete/save).
|
||||||
|
- Positive qualitative feedback on responsiveness in editor/grid interactions.
|
||||||
54
prd/migration-advice-next-vs-sveltekit.md
Normal file
54
prd/migration-advice-next-vs-sveltekit.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Migration Advice: Next App Router + next-intl vs. SvelteKit
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Small hobby app for a niche game community: create/share team compositions, with aspirations for collection tracking. Current stack is Next.js with a hybrid of Pages + App Router and `next-i18next`. The path forward considers migrating to Next 15 with `next-intl` or rewriting in SvelteKit.
|
||||||
|
|
||||||
|
## Opinionated Summary
|
||||||
|
- If you stay on Next: unify on App Router and `next-intl`. It’s the idiomatic, low-risk path on Next 15 and removes hybrid friction.
|
||||||
|
- If you prefer SvelteKit and want a better DX long term: a focused rewrite is reasonable for this scope. Expect higher upfront cost, but likely faster iteration afterwards.
|
||||||
|
|
||||||
|
## When Staying on Next
|
||||||
|
- App Router: move fully off Pages Router to reduce complexity.
|
||||||
|
- i18n: adopt `next-intl` idioms.
|
||||||
|
- Localized segment `app/[locale]/layout.tsx` with `unstable_setRequestLocale` and `getMessages()`.
|
||||||
|
- Compose middleware: run `next-intl` first, then auth; strip locale before auth checks.
|
||||||
|
- Replace `next/link` and `next/navigation` with `next-intl/navigation` to preserve locale.
|
||||||
|
- Replace `useTranslation` with `useTranslations` and `<Trans>` with `t.rich()`.
|
||||||
|
- Localize metadata via `getTranslations` in `generateMetadata`.
|
||||||
|
- Remove `i18n` from `next.config.js`; delete `next-i18next.config.js` when done.
|
||||||
|
- Effort: medium. Many small file touches, predictable changes, minimal risk.
|
||||||
|
|
||||||
|
## When Switching to SvelteKit
|
||||||
|
- Fit: Excellent for small, interactive apps; simpler routing and data loading; stores are ergonomic; great DX.
|
||||||
|
- i18n: Use `svelte-i18n` or `typesafe-i18n`; reuse existing JSON namespaces.
|
||||||
|
- Migration outline:
|
||||||
|
- Rebuild routes and core UI (builder, share pages) first.
|
||||||
|
- Port styles and assets; map API endpoints or use SvelteKit endpoints.
|
||||||
|
- Re-implement auth/cookies and URL structure for shareability.
|
||||||
|
- Add collection tracking after the core flow is stable.
|
||||||
|
- Effort: medium–high. A rewrite is real work, but constrained domain makes it feasible.
|
||||||
|
|
||||||
|
## Decision Guidance
|
||||||
|
- Choose Next if you want quickest path to stability with current code.
|
||||||
|
- Choose SvelteKit if you value developer experience and faster iteration beyond the migration.
|
||||||
|
|
||||||
|
## Practical Next Steps (Next.js Path)
|
||||||
|
1. Create `i18n.config.ts` with `locales` and `defaultLocale`.
|
||||||
|
2. Compose `middleware.ts` with `next-intl/middleware` first, then auth (strip locale).
|
||||||
|
3. Add `app/[locale]/layout.tsx` with `unstable_setRequestLocale` + `getMessages()` and `NextIntlClientProvider`.
|
||||||
|
4. Replace `next/link` and `next/navigation` with `next-intl/navigation`.
|
||||||
|
5. Swap `next-i18next` usages for `next-intl` (`useTranslations`, `t.rich`).
|
||||||
|
6. Localize `generateMetadata`; update server-only paths to use `getTranslations`.
|
||||||
|
7. Remove `i18n` from `next.config.js`; delete `next-i18next.config.js` and dependency.
|
||||||
|
8. Remove Pages Router pages once App Router feature parity is confirmed.
|
||||||
|
|
||||||
|
## Practical Next Steps (SvelteKit Path)
|
||||||
|
1. Prototype the builder and a share page in SvelteKit.
|
||||||
|
2. Port translations; wire `svelte-i18n` or `typesafe-i18n`.
|
||||||
|
3. Recreate auth/session and URL structures.
|
||||||
|
4. Decide incremental migration (subdomain) vs. cutover.
|
||||||
|
5. Migrate remaining features; add collection tracking last.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
Stabilize quickly by finishing the Next App Router + `next-intl` migration you’ve already started. If Next still feels clunky after that, plan a small SvelteKit prototype; if the DX clicks, proceed with a focused rewrite.
|
||||||
|
|
||||||
Loading…
Reference in a new issue