Skip to content

Commit 7b8a094

Browse files
committed
feat(i18n): add comprehensive Chinese localization support
- Implement next-intl for bilingual support (English/Chinese Simplified) - Add language switcher component with locale persistence - Add Chinese (zh) and English (en) translation files (400+ keys) - Translate all major UI components (95%+ coverage): * Homepage (hero, features, integrations, CTA) * Authentication pages (login, register) * Dashboard and all management pages * Researcher/Chat interface with full conversation flow * Connectors: Add, Manage, Configure (all categories) * Documents: Upload, Manage, Viewer * Logs, Podcasts pages * Settings: LLM configs, Role assignments * Onboarding: Add Provider, Assign Roles steps - Add middleware for locale detection and routing - Add LocaleContext for global locale state management - Translate all connector descriptions for i18n - Update breadcrumb navigation with translations - Add Chinese date/time formatting - Fix code quality issues in documents_routes.py and users.py - Update .gitignore to exclude .pnpm-store This makes SurfSense fully accessible to Chinese users while maintaining complete English support for international users.
1 parent 8aeaf41 commit 7b8a094

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+3253
-963
lines changed

surfsense_backend/app/routes/documents_routes.py

Lines changed: 613 additions & 94 deletions
Large diffs are not rendered by default.

surfsense_backend/app/users.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
JWTStrategy,
1010
)
1111
from fastapi_users.db import SQLAlchemyUserDatabase
12-
from fastapi_users.schemas import model_dump
1312
from pydantic import BaseModel
1413

1514
from app.config import config
@@ -86,7 +85,7 @@ async def get_login_response(self, token: str) -> Response:
8685
if config.AUTH_TYPE == "GOOGLE":
8786
return RedirectResponse(redirect_url, status_code=302)
8887
else:
89-
return JSONResponse(model_dump(bearer_response))
88+
return JSONResponse(bearer_response.model_dump())
9089

9190

9291
bearer_transport = CustomBearerTransport(tokenUrl="auth/jwt/login")

surfsense_web/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,6 @@ next-env.d.ts
4747

4848
# source
4949
/.source/
50+
51+
.pnpm-store/
52+

surfsense_web/app/(home)/login/GoogleLoginButton.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"use client";
22
import { IconBrandGoogleFilled } from "@tabler/icons-react";
33
import { motion } from "motion/react";
4+
import { useTranslations } from "next-intl";
45
import { Logo } from "@/components/Logo";
56
import { AmbientBackground } from "./AmbientBackground";
67

78
export function GoogleLoginButton() {
9+
const t = useTranslations('auth');
10+
811
const handleGoogleLogin = () => {
912
// Redirect to Google OAuth authorization URL
1013
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
@@ -31,7 +34,7 @@ export function GoogleLoginButton() {
3134
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
3235
<Logo className="rounded-md" />
3336
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
34-
Welcome Back
37+
{t('welcome_back')}
3538
</h1>
3639

3740
<motion.div
@@ -65,14 +68,14 @@ export function GoogleLoginButton() {
6568
</svg>
6669
<div className="ml-1">
6770
<p className="text-sm font-medium">
68-
SurfSense Cloud is currently in development. Check{" "}
71+
{t('cloud_dev_notice')}{" "}
6972
<a
7073
href="/docs"
7174
className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
7275
>
73-
Docs
76+
{t('docs')}
7477
</a>{" "}
75-
for more information on Self-Hosted version.
78+
{t('cloud_dev_self_hosted')}
7679
</p>
7780
</div>
7881
</motion.div>
@@ -91,7 +94,7 @@ export function GoogleLoginButton() {
9194
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
9295
</div>
9396
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
94-
<span className="text-base font-medium">Continue with Google</span>
97+
<span className="text-base font-medium">{t('continue_with_google')}</span>
9598
</motion.button>
9699
</div>
97100
</div>

surfsense_web/app/(home)/login/LocalLoginForm.tsx

Lines changed: 72 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import Link from "next/link";
55
import { useRouter } from "next/navigation";
66
import { useEffect, useState } from "react";
77
import { toast } from "sonner";
8+
import { useTranslations } from "next-intl";
89
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
910

1011
export function LocalLoginForm() {
12+
const t = useTranslations('auth');
13+
const tCommon = useTranslations('common');
1114
const [username, setUsername] = useState("");
1215
const [password, setPassword] = useState("");
1316
const [showPassword, setShowPassword] = useState(false);
@@ -29,7 +32,7 @@ export function LocalLoginForm() {
2932
setErrorTitle(null);
3033

3134
// Show loading toast
32-
const loadingToast = toast.loading("Signing you in...");
35+
const loadingToast = toast.loading(tCommon('loading'));
3336

3437
try {
3538
// Create form data for the API request
@@ -56,7 +59,7 @@ export function LocalLoginForm() {
5659
}
5760

5861
// Success toast
59-
toast.success("Login successful!", {
62+
toast.success(t('login_success'), {
6063
id: loadingToast,
6164
description: "Redirecting to dashboard...",
6265
duration: 2000,
@@ -167,84 +170,84 @@ export function LocalLoginForm() {
167170
</div>
168171
</motion.div>
169172
)}
170-
</AnimatePresence>
173+
</AnimatePresence>
171174

172-
<div>
173-
<label
174-
htmlFor="email"
175-
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
176-
>
177-
Email
178-
</label>
175+
<div>
176+
<label
177+
htmlFor="email"
178+
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
179+
>
180+
{t('email')}
181+
</label>
182+
<input
183+
id="email"
184+
type="email"
185+
required
186+
value={username}
187+
onChange={(e) => setUsername(e.target.value)}
188+
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
189+
error
190+
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
191+
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
192+
}`}
193+
disabled={isLoading}
194+
/>
195+
</div>
196+
197+
<div>
198+
<label
199+
htmlFor="password"
200+
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
201+
>
202+
{t('password')}
203+
</label>
204+
<div className="relative">
179205
<input
180-
id="email"
181-
type="email"
206+
id="password"
207+
type={showPassword ? "text" : "password"}
182208
required
183-
value={username}
184-
onChange={(e) => setUsername(e.target.value)}
185-
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
209+
value={password}
210+
onChange={(e) => setPassword(e.target.value)}
211+
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
186212
error
187213
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
188214
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
189215
}`}
190216
disabled={isLoading}
191217
/>
192-
</div>
193-
194-
<div>
195-
<label
196-
htmlFor="password"
197-
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
218+
<button
219+
type="button"
220+
onClick={() => setShowPassword((prev) => !prev)}
221+
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
222+
aria-label={showPassword ? t('hide_password') : t('show_password')}
198223
>
199-
Password
200-
</label>
201-
<div className="relative">
202-
<input
203-
id="password"
204-
type={showPassword ? "text" : "password"}
205-
required
206-
value={password}
207-
onChange={(e) => setPassword(e.target.value)}
208-
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
209-
error
210-
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
211-
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
212-
}`}
213-
disabled={isLoading}
214-
/>
215-
<button
216-
type="button"
217-
onClick={() => setShowPassword((prev) => !prev)}
218-
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
219-
aria-label={showPassword ? "Hide password" : "Show password"}
220-
>
221-
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
222-
</button>
223-
</div>
224+
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
225+
</button>
224226
</div>
225-
226-
<button
227-
type="submit"
228-
disabled={isLoading}
229-
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
230-
>
231-
{isLoading ? "Signing in..." : "Sign in"}
232-
</button>
233-
</form>
234-
235-
{authType === "LOCAL" && (
236-
<div className="mt-4 text-center text-sm">
237-
<p className="text-gray-600 dark:text-gray-400">
238-
Don&apos;t have an account?{" "}
239-
<Link
240-
href="/register"
241-
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
242-
>
243-
Register here
244-
</Link>
245-
</p>
246-
</div>
247-
)}
227+
</div>
228+
229+
<button
230+
type="submit"
231+
disabled={isLoading}
232+
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
233+
>
234+
{isLoading ? tCommon('loading') : t('sign_in')}
235+
</button>
236+
</form>
237+
238+
{authType === "LOCAL" && (
239+
<div className="mt-4 text-center text-sm">
240+
<p className="text-gray-600 dark:text-gray-400">
241+
{t('dont_have_account')}{" "}
242+
<Link
243+
href="/register"
244+
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
245+
>
246+
{t('sign_up')}
247+
</Link>
248+
</p>
249+
</div>
250+
)}
248251
</div>
249252
);
250253
}

surfsense_web/app/(home)/login/page.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import { AnimatePresence, motion } from "motion/react";
55
import { useSearchParams } from "next/navigation";
66
import { Suspense, useEffect, useState } from "react";
77
import { toast } from "sonner";
8+
import { useTranslations } from "next-intl";
89
import { Logo } from "@/components/Logo";
910
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
1011
import { AmbientBackground } from "./AmbientBackground";
1112
import { GoogleLoginButton } from "./GoogleLoginButton";
1213
import { LocalLoginForm } from "./LocalLoginForm";
1314

1415
function LoginContent() {
16+
const t = useTranslations('auth');
17+
const tCommon = useTranslations('common');
1518
const [authType, setAuthType] = useState<string | null>(null);
1619
const [isLoading, setIsLoading] = useState(true);
1720
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
@@ -26,15 +29,15 @@ function LoginContent() {
2629

2730
// Show registration success message
2831
if (registered === "true") {
29-
toast.success("Registration successful!", {
30-
description: "You can now sign in with your credentials",
32+
toast.success(t('register_success'), {
33+
description: t('login_subtitle'),
3134
duration: 5000,
3235
});
3336
}
3437

3538
// Show logout confirmation
3639
if (logout === "true") {
37-
toast.success("Logged out successfully", {
40+
toast.success(tCommon('success'), {
3841
description: "You have been securely logged out",
3942
duration: 3000,
4043
});
@@ -93,7 +96,7 @@ function LoginContent() {
9396
<Logo className="rounded-md" />
9497
<div className="mt-8 flex items-center space-x-2">
9598
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
96-
<span className="text-muted-foreground">Loading...</span>
99+
<span className="text-muted-foreground">{tCommon('loading')}</span>
97100
</div>
98101
</div>
99102
</div>
@@ -110,7 +113,7 @@ function LoginContent() {
110113
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
111114
<Logo className="rounded-md" />
112115
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
113-
Sign In
116+
{t('sign_in')}
114117
</h1>
115118

116119
{/* URL Error Display */}

0 commit comments

Comments
 (0)