feat(web-i18n): add es/fr locales and expand switcher locale set
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from "next"
|
||||
import createNextIntlPlugin from "next-intl/plugin"
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts")
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ["@cms/ui", "@cms/content", "@cms/db"],
|
||||
transpilePackages: ["@cms/ui", "@cms/content", "@cms/db", "@cms/i18n"],
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
export default withNextIntl(nextConfig)
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
"dependencies": {
|
||||
"@cms/content": "workspace:*",
|
||||
"@cms/db": "workspace:*",
|
||||
"@cms/i18n": "workspace:*",
|
||||
"@cms/ui": "workspace:*",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "4.4.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "5.0.11"
|
||||
|
||||
27
apps/web/src/app/[locale]/layout.tsx
Normal file
27
apps/web/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { hasLocale, NextIntlClientProvider } from "next-intl"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { routing } from "@/i18n/routing"
|
||||
import { Providers } from "../providers"
|
||||
|
||||
type LocaleLayoutProps = {
|
||||
children: ReactNode
|
||||
params: Promise<{
|
||||
locale: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale } = await params
|
||||
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale}>
|
||||
<Providers>{children}</Providers>
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
import { listPosts } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function HomePage() {
|
||||
const posts = await listPosts()
|
||||
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-16">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Web App</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Your Next.js CMS Frontend</h1>
|
||||
<p className="text-neutral-600">
|
||||
This page reads posts through the shared database package.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-neutral-600">{t("description")}</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">Latest posts</h2>
|
||||
<Button variant="secondary">Explore</Button>
|
||||
<h2 className="text-xl font-medium">{t("latestPosts")}</h2>
|
||||
<Button variant="secondary">{t("explore")}</Button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
@@ -27,7 +31,7 @@ export default async function HomePage() {
|
||||
<li key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
|
||||
<h3 className="mt-1 text-lg font-medium">{post.title}</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import "./globals.css"
|
||||
import { Providers } from "./providers"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CMS Web",
|
||||
@@ -12,9 +11,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
50
apps/web/src/components/language-switcher.tsx
Normal file
50
apps/web/src/components/language-switcher.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { type AppLocale, localeLabels, locales } from "@cms/i18n"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { useEffect, useTransition } from "react"
|
||||
|
||||
import { usePathname, useRouter } from "@/i18n/navigation"
|
||||
import { useLocaleStore } from "@/store/locale"
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const t = useTranslations("LanguageSwitcher")
|
||||
const currentLocale = useLocale() as AppLocale
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const locale = useLocaleStore((state) => state.locale)
|
||||
const setLocale = useLocaleStore((state) => state.setLocale)
|
||||
|
||||
useEffect(() => {
|
||||
if (locale !== currentLocale) {
|
||||
setLocale(currentLocale)
|
||||
}
|
||||
}, [currentLocale, locale, setLocale])
|
||||
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<span>{t("label")}</span>
|
||||
<select
|
||||
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm"
|
||||
value={locale}
|
||||
disabled={isPending}
|
||||
onChange={(event) => {
|
||||
const nextLocale = event.target.value as AppLocale
|
||||
setLocale(nextLocale)
|
||||
|
||||
startTransition(() => {
|
||||
router.replace(pathname, { locale: nextLocale })
|
||||
})
|
||||
}}
|
||||
>
|
||||
{locales.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`localeNames.${value}`)} ({localeLabels[value]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
5
apps/web/src/i18n/navigation.ts
Normal file
5
apps/web/src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createNavigation } from "next-intl/navigation"
|
||||
|
||||
import { routing } from "./routing"
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
|
||||
14
apps/web/src/i18n/request.ts
Normal file
14
apps/web/src/i18n/request.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { hasLocale } from "next-intl"
|
||||
import { getRequestConfig } from "next-intl/server"
|
||||
|
||||
import { routing } from "./routing"
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const requested = await requestLocale
|
||||
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
}
|
||||
})
|
||||
8
apps/web/src/i18n/routing.ts
Normal file
8
apps/web/src/i18n/routing.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defaultLocale, locales } from "@cms/i18n"
|
||||
import { defineRouting } from "next-intl/routing"
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: [...locales],
|
||||
defaultLocale,
|
||||
localePrefix: "never",
|
||||
})
|
||||
19
apps/web/src/messages/de.json
Normal file
19
apps/web/src/messages/de.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Home": {
|
||||
"badge": "Web-App",
|
||||
"title": "Dein Next.js CMS Frontend",
|
||||
"description": "Diese Seite liest Beiträge über das gemeinsame Datenbank-Paket.",
|
||||
"latestPosts": "Neueste Beiträge",
|
||||
"explore": "Entdecken",
|
||||
"noExcerpt": "Kein Auszug"
|
||||
},
|
||||
"LanguageSwitcher": {
|
||||
"label": "Sprache",
|
||||
"localeNames": {
|
||||
"de": "Deutsch",
|
||||
"en": "Englisch",
|
||||
"es": "Spanisch",
|
||||
"fr": "Französisch"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/web/src/messages/en.json
Normal file
19
apps/web/src/messages/en.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Home": {
|
||||
"badge": "Web App",
|
||||
"title": "Your Next.js CMS Frontend",
|
||||
"description": "This page reads posts through the shared database package.",
|
||||
"latestPosts": "Latest posts",
|
||||
"explore": "Explore",
|
||||
"noExcerpt": "No excerpt"
|
||||
},
|
||||
"LanguageSwitcher": {
|
||||
"label": "Language",
|
||||
"localeNames": {
|
||||
"de": "German",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"fr": "French"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/web/src/messages/es.json
Normal file
19
apps/web/src/messages/es.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Home": {
|
||||
"badge": "Aplicación Web",
|
||||
"title": "Tu Frontend CMS con Next.js",
|
||||
"description": "Esta página lee publicaciones a través del paquete compartido de base de datos.",
|
||||
"latestPosts": "Últimas publicaciones",
|
||||
"explore": "Explorar",
|
||||
"noExcerpt": "Sin extracto"
|
||||
},
|
||||
"LanguageSwitcher": {
|
||||
"label": "Idioma",
|
||||
"localeNames": {
|
||||
"de": "Alemán",
|
||||
"en": "Inglés",
|
||||
"es": "Español",
|
||||
"fr": "Francés"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/web/src/messages/fr.json
Normal file
19
apps/web/src/messages/fr.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Home": {
|
||||
"badge": "Application Web",
|
||||
"title": "Votre Frontend CMS Next.js",
|
||||
"description": "Cette page lit les publications via le package base de données partagé.",
|
||||
"latestPosts": "Dernières publications",
|
||||
"explore": "Explorer",
|
||||
"noExcerpt": "Aucun extrait"
|
||||
},
|
||||
"LanguageSwitcher": {
|
||||
"label": "Langue",
|
||||
"localeNames": {
|
||||
"de": "Allemand",
|
||||
"en": "Anglais",
|
||||
"es": "Espagnol",
|
||||
"fr": "Français"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/web/src/proxy.ts
Normal file
14
apps/web/src/proxy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { NextRequest } from "next/server"
|
||||
import createMiddleware from "next-intl/middleware"
|
||||
|
||||
import { routing } from "@/i18n/routing"
|
||||
|
||||
const handleI18nRouting = createMiddleware(routing)
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
return handleI18nRouting(request)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|trpc|_next|_vercel|.*\\..*).*)"],
|
||||
}
|
||||
12
apps/web/src/store/locale.test.ts
Normal file
12
apps/web/src/store/locale.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { useLocaleStore } from "./locale"
|
||||
|
||||
describe("web locale store", () => {
|
||||
it("sets locale", () => {
|
||||
useLocaleStore.setState({ locale: "en" })
|
||||
useLocaleStore.getState().setLocale("de")
|
||||
|
||||
expect(useLocaleStore.getState().locale).toBe("de")
|
||||
})
|
||||
})
|
||||
12
apps/web/src/store/locale.ts
Normal file
12
apps/web/src/store/locale.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { type AppLocale, defaultLocale } from "@cms/i18n"
|
||||
import { create } from "zustand"
|
||||
|
||||
type LocaleStore = {
|
||||
locale: AppLocale
|
||||
setLocale: (value: AppLocale) => void
|
||||
}
|
||||
|
||||
export const useLocaleStore = create<LocaleStore>((set) => ({
|
||||
locale: defaultLocale,
|
||||
setLocale: (value) => set({ locale: value }),
|
||||
}))
|
||||
Reference in New Issue
Block a user