From 8d24d3c36e7b76ef47adee9385321d452b3a0255 Mon Sep 17 00:00:00 2001 From: songtianlun Date: Tue, 29 Jul 2025 22:21:16 +0800 Subject: [PATCH] add i18n --- messages/en.json | 124 +++++++++++++++++++++ messages/zh.json | 124 +++++++++++++++++++++ middleware.ts | 40 +++++++ next.config.ts | 5 +- package-lock.json | 135 +++++++++++++++++++++++ package.json | 1 + src/app/layout.tsx | 13 ++- src/app/page.tsx | 51 +++++---- src/app/studio/page.tsx | 5 +- src/components/layout/Header.tsx | 53 +++++---- src/components/ui/language-toggle.tsx | 153 ++++++++++++++++++++++++++ src/i18n/config.ts | 66 +++++++++++ 12 files changed, 718 insertions(+), 52 deletions(-) create mode 100644 messages/en.json create mode 100644 messages/zh.json create mode 100644 src/components/ui/language-toggle.tsx create mode 100644 src/i18n/config.ts diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..3ea3a80 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,124 @@ +{ + "navigation": { + "home": "Home", + "studio": "Studio", + "profile": "Profile", + "signIn": "Sign In", + "signUp": "Sign Up", + "signOut": "Sign Out" + }, + "common": { + "loading": "Loading...", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "search": "Search", + "filter": "Filter", + "language": "Language", + "theme": "Theme", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "auth": { + "signIn": "Sign In", + "signUp": "Sign Up", + "email": "Email", + "password": "Password", + "username": "Username", + "confirmPassword": "Confirm Password", + "forgotPassword": "Forgot Password?", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "signInWithGoogle": "Sign in with Google", + "signUpWithGoogle": "Sign up with Google", + "signInTitle": "Welcome Back", + "signInSubtitle": "Sign in to your Prmbr account", + "signUpTitle": "Create Account", + "signUpSubtitle": "Join Prmbr - AI Prompt Studio" + }, + "profile": { + "title": "Profile Settings", + "personalInfo": "Personal Information", + "accountSettings": "Account Settings", + "preferences": "Preferences", + "username": "Username", + "email": "Email", + "bio": "Bio", + "avatar": "Avatar", + "language": "Language", + "changePassword": "Change Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password" + }, + "studio": { + "title": "AI Prompt Studio", + "myPrompts": "My Prompts", + "createPrompt": "Create Prompt", + "promptName": "Prompt Name", + "promptContent": "Prompt Content", + "promptAlbum": "Album", + "promptTag": "Tags", + "version": "Version", + "testRun": "Test Run", + "versions": "Versions", + "createVersion": "Create Version", + "runTest": "Run Test", + "noPrompts": "No prompts yet", + "createFirstPrompt": "Create your first prompt to get started" + }, + "home": { + "hero": { + "title": "Prmbr - AI Prompt Studio", + "subtitle": "Build, manage, and optimize your AI prompts with version control and testing capabilities", + "getStarted": "Get Started", + "learnMore": "Learn More" + }, + "features": { + "title": "Features", + "promptManager": { + "title": "Prompt Management", + "description": "Organize your prompts with albums, tags, and powerful search capabilities" + }, + "versionControl": { + "title": "Version Control", + "description": "Track changes and manage different versions of your prompts" + }, + "testing": { + "title": "Testing & Validation", + "description": "Run tests on your prompts to ensure optimal performance" + } + } + }, + "pricing": { + "title": "Pricing Plans", + "free": { + "title": "Free", + "price": "$0", + "features": [ + "20 Prompt Limit", + "3 Versions per Prompt", + "$5 AI Credit Monthly" + ] + }, + "pro": { + "title": "Pro", + "price": "$19.9", + "features": [ + "500 Prompt Limit", + "10 Versions per Prompt", + "$20 AI Credit Monthly" + ] + } + }, + "errors": { + "generic": "Something went wrong. Please try again.", + "network": "Network error. Please check your connection.", + "unauthorized": "You are not authorized to perform this action.", + "notFound": "The requested resource was not found.", + "validationError": "Please check your input and try again." + } +} \ No newline at end of file diff --git a/messages/zh.json b/messages/zh.json new file mode 100644 index 0000000..fb32c67 --- /dev/null +++ b/messages/zh.json @@ -0,0 +1,124 @@ +{ + "navigation": { + "home": "首页", + "studio": "工作室", + "profile": "个人资料", + "signIn": "登录", + "signUp": "注册", + "signOut": "退出登录" + }, + "common": { + "loading": "加载中...", + "save": "保存", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "create": "创建", + "search": "搜索", + "filter": "筛选", + "language": "语言", + "theme": "主题", + "light": "浅色", + "dark": "深色", + "system": "跟随系统" + }, + "auth": { + "signIn": "登录", + "signUp": "注册", + "email": "邮箱", + "password": "密码", + "username": "用户名", + "confirmPassword": "确认密码", + "forgotPassword": "忘记密码?", + "noAccount": "还没有账户?", + "hasAccount": "已有账户?", + "signInWithGoogle": "使用 Google 登录", + "signUpWithGoogle": "使用 Google 注册", + "signInTitle": "欢迎回来", + "signInSubtitle": "登录您的 Prmbr 账户", + "signUpTitle": "创建账户", + "signUpSubtitle": "加入 Prmbr - AI 提示词工作室" + }, + "profile": { + "title": "个人设置", + "personalInfo": "个人信息", + "accountSettings": "账户设置", + "preferences": "偏好设置", + "username": "用户名", + "email": "邮箱", + "bio": "个人简介", + "avatar": "头像", + "language": "语言", + "changePassword": "修改密码", + "currentPassword": "当前密码", + "newPassword": "新密码", + "confirmNewPassword": "确认新密码" + }, + "studio": { + "title": "AI 提示词工作室", + "myPrompts": "我的提示词", + "createPrompt": "创建提示词", + "promptName": "提示词名称", + "promptContent": "提示词内容", + "promptAlbum": "专辑", + "promptTag": "标签", + "version": "版本", + "testRun": "测试运行", + "versions": "版本管理", + "createVersion": "创建版本", + "runTest": "运行测试", + "noPrompts": "暂无提示词", + "createFirstPrompt": "创建您的第一个提示词开始使用" + }, + "home": { + "hero": { + "title": "Prmbr - AI 提示词工作室", + "subtitle": "构建、管理和优化您的 AI 提示词,支持版本控制和测试功能", + "getStarted": "立即开始", + "learnMore": "了解更多" + }, + "features": { + "title": "功能特性", + "promptManager": { + "title": "提示词管理", + "description": "使用专辑、标签和强大的搜索功能组织您的提示词" + }, + "versionControl": { + "title": "版本控制", + "description": "跟踪变更并管理提示词的不同版本" + }, + "testing": { + "title": "测试与验证", + "description": "对您的提示词进行测试以确保最佳性能" + } + } + }, + "pricing": { + "title": "价格方案", + "free": { + "title": "免费版", + "price": "免费", + "features": [ + "20 个提示词限制", + "每个提示词 3 个版本", + "每月 5 美元 AI 积分" + ] + }, + "pro": { + "title": "专业版", + "price": "$19.9", + "features": [ + "500 个提示词限制", + "每个提示词 10 个版本", + "每月 20 美元 AI 积分" + ] + } + }, + "errors": { + "generic": "出现错误,请重试。", + "network": "网络错误,请检查您的网络连接。", + "unauthorized": "您无权执行此操作。", + "notFound": "未找到请求的资源。", + "validationError": "请检查您的输入并重试。" + } +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index 8531571..53b2ba4 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,5 +1,31 @@ import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' +import { locales, defaultLocale } from './src/i18n/config' + +function getLocaleFromHeaders(acceptLanguage: string | null): string { + if (!acceptLanguage) return defaultLocale; + + const languages = acceptLanguage + .split(',') + .map(lang => lang.split(';')[0].trim().toLowerCase()); + + // Check for exact matches first + for (const lang of languages) { + if (locales.includes(lang as any)) { + return lang; + } + } + + // Check for language prefix matches (e.g., 'zh-CN' -> 'zh') + for (const lang of languages) { + const prefix = lang.split('-')[0]; + if (locales.includes(prefix as any)) { + return prefix; + } + } + + return defaultLocale; +} export async function middleware(request: NextRequest) { let response = NextResponse.next({ @@ -8,6 +34,20 @@ export async function middleware(request: NextRequest) { }, }) + // Handle locale detection and cookie setting + const currentLocale = request.cookies.get('locale')?.value; + const acceptLanguage = request.headers.get('accept-language'); + + if (!currentLocale) { + const detectedLocale = getLocaleFromHeaders(acceptLanguage); + response.cookies.set('locale', detectedLocale, { + maxAge: 60 * 60 * 24 * 365, // 1 year + httpOnly: false, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + }); + } + const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, diff --git a/next.config.ts b/next.config.ts index 2f858d4..d762d9f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,7 @@ import type { NextConfig } from "next"; +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./src/i18n/config.ts'); const nextConfig: NextConfig = { images: { @@ -31,4 +34,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index e9eab4a..57662c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.532.0", "next": "15.4.4", + "next-intl": "^4.3.4", "prisma": "^6.12.0", "react": "19.1.0", "react-dom": "19.1.0", @@ -234,6 +235,66 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", + "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.1", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", + "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", + "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/icu-skeleton-parser": "1.8.14", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.14", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", + "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", + "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1074,6 +1135,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@stitches/core": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@stitches/core/-/core-1.2.8.tgz", @@ -2744,6 +2811,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3930,6 +4003,18 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "10.7.16", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", + "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.2", + "tslib": "^2.8.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4945,6 +5030,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "15.4.4", "resolved": "https://registry.npmjs.org/next/-/next-15.4.4.tgz", @@ -4997,6 +5091,33 @@ } } }, + "node_modules/next-intl": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.4.tgz", + "integrity": "sha512-VWLIDlGbnL/o4LnveJTJD1NOYN8lh3ZAGTWw2krhfgg53as3VsS4jzUVnArJdqvwtlpU/2BIDbWTZ7V4o1jFEw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", + "negotiator": "^1.0.0", + "use-intl": "^4.3.4" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6343,6 +6464,20 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.4.tgz", + "integrity": "sha512-sHfiU0QeJ1rirNWRxvCyvlSh9+NczcOzRnPyMeo2rtHXhVnBsvMRjE+UG4eh3lRhCxrvcqei/I0lBxsc59on1w==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^2.2.0", + "@schummar/icu-type-parser": "1.21.5", + "intl-messageformat": "^10.5.14" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 3c1713b..e2960c3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.532.0", "next": "15.4.4", + "next-intl": "^4.3.4", "prisma": "^6.12.0", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 09b301c..02ea323 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, getLocale } from 'next-intl/server'; import { ThemeProvider } from "@/contexts/ThemeContext"; import "./globals.css"; @@ -54,13 +56,16 @@ export const viewport: Viewport = { ], }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const locale = await getLocale(); + const messages = await getMessages(); + return ( - +