feat(i18n): Add next-intl framework with full bilingual support (EN/ZH)

- Implement next-intl framework for scalable i18n
- Add complete Chinese (Simplified) localization
- Support 400+ translated strings across all pages
- Add language switcher with persistent preference
- Zero breaking changes to existing functionality

Framework additions:
- i18n routing and middleware
- LocaleContext for client-side state
- LanguageSwitcher component
- Translation files (en.json, zh.json)

Translated components:
- Homepage: Hero, features, CTA, navbar
- Auth: Login, register
- Dashboard: Main page, layout
- Connectors: Management, add page (all categories)
- Documents: Upload, manage, filters
- Settings: LLM configs, role assignments
- Onboarding: Add provider, assign roles
- Logs: Task logs viewer

Adding a new language now requires only:
1. Create messages/<locale>.json
2. Add locale to i18n/routing.ts
This commit is contained in:
Differ 2025-10-26 14:05:46 +08:00
parent 8aeaf419d0
commit f58c7e4602
37 changed files with 2267 additions and 542 deletions

View file

@ -2,6 +2,7 @@
import { Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button";
import {
@ -55,6 +56,8 @@ export function AppSidebarProvider({
navSecondary,
navMain,
}: AppSidebarProviderProps) {
const t = useTranslations('dashboard');
const tCommon = useTranslations('common');
const [recentChats, setRecentChats] = useState<
{
name: string;
@ -196,14 +199,14 @@ export function AppSidebarProvider({
if (chatError) {
return [
{
name: "Error loading chats",
name: t('error_loading_chats'),
url: "#",
icon: "AlertCircle",
id: 0,
search_space_id: Number(searchSpaceId),
actions: [
{
name: "Retry",
name: tCommon('retry'),
icon: "RefreshCw",
onClick: retryFetch,
},
@ -215,7 +218,7 @@ export function AppSidebarProvider({
if (!isLoadingChats && recentChats.length === 0) {
return [
{
name: "No recent chats",
name: t('no_recent_chats'),
url: "#",
icon: "MessageCircleMore",
id: 0,
@ -226,7 +229,7 @@ export function AppSidebarProvider({
}
return [];
}, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch]);
}, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch, t, tCommon]);
// Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
@ -240,14 +243,14 @@ export function AppSidebarProvider({
title:
searchSpace?.name ||
(isLoadingSearchSpace
? "Loading..."
? tCommon('loading')
: searchSpaceError
? "Error loading search space"
: "Unknown Search Space"),
? t('error_loading_space')
: t('unknown_search_space')),
};
}
return updated;
}, [navSecondary, isClient, searchSpace?.name, isLoadingSearchSpace, searchSpaceError]);
}, [navSecondary, isClient, searchSpace?.name, isLoadingSearchSpace, searchSpaceError, t, tCommon]);
// Show loading state if not client-side
if (!isClient) {
@ -264,12 +267,11 @@ export function AppSidebarProvider({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Chat</span>
<span>{t('delete_chat')}</span>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-medium">{chatToDelete?.name}</span>? This action cannot be
undone.
{t('delete_chat_confirm')}{" "}
<span className="font-medium">{chatToDelete?.name}</span>? {t('action_cannot_undone')}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
@ -278,7 +280,7 @@ export function AppSidebarProvider({
onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting}
>
Cancel
{tCommon('cancel')}
</Button>
<Button
variant="destructive"
@ -289,12 +291,12 @@ export function AppSidebarProvider({
{isDeleting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting...
{t('deleting')}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
{tCommon('delete')}
</>
)}
</Button>

View file

@ -2,6 +2,7 @@
import { ChevronRight, type LucideIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
@ -28,57 +29,87 @@ interface NavItem {
}
export function NavMain({ items }: { items: NavItem[] }) {
const t = useTranslations('nav_menu');
// Translation function that handles both exact matches and fallback to original
const translateTitle = (title: string): string => {
const titleMap: Record<string, string> = {
'Researcher': 'researcher',
'Manage LLMs': 'manage_llms',
'Documents': 'documents',
'Upload Documents': 'upload_documents',
'Add Webpages': 'add_webpages',
'Add Youtube Videos': 'add_youtube',
'Manage Documents': 'manage_documents',
'Connectors': 'connectors',
'Add Connector': 'add_connector',
'Manage Connectors': 'manage_connectors',
'Podcasts': 'podcasts',
'Logs': 'logs',
'Platform': 'platform',
};
const key = titleMap[title];
return key ? t(key) : title;
};
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>{translateTitle('Platform')}</SidebarGroupLabel>
<SidebarMenu>
{memoizedItems.map((item, index) => (
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={item.isActive}
aria-label={`${item.title}${item.items?.length ? " with submenu" : ""}`}
>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
{memoizedItems.map((item, index) => {
const translatedTitle = translateTitle(item.title);
return (
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={translatedTitle}
isActive={item.isActive}
aria-label={`${translatedTitle}${item.items?.length ? " with submenu" : ""}`}
>
<a href={item.url}>
<item.icon />
<span>{translatedTitle}</span>
</a>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction
className="data-[state=open]:rotate-90 transition-transform duration-200"
aria-label={`Toggle ${item.title} submenu`}
>
<ChevronRight />
<span className="sr-only">Toggle submenu</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 duration-200">
<SidebarMenuSub>
{item.items?.map((subItem, subIndex) => (
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
<SidebarMenuSubButton asChild aria-label={subItem.title}>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction
className="data-[state=open]:rotate-90 transition-transform duration-200"
aria-label={`Toggle ${translatedTitle} submenu`}
>
<ChevronRight />
<span className="sr-only">Toggle submenu</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 duration-200">
<SidebarMenuSub>
{item.items?.map((subItem, subIndex) => {
const translatedSubTitle = translateTitle(subItem.title);
return (
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
<SidebarMenuSubButton asChild aria-label={translatedSubTitle}>
<a href={subItem.url}>
<span>{translatedSubTitle}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroup>
);

View file

@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuContent,
@ -56,6 +57,7 @@ interface ChatItem {
}
export function NavProjects({ chats }: { chats: ChatItem[] }) {
const t = useTranslations('sidebar');
const { isMobile } = useSidebar();
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
@ -145,13 +147,13 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupLabel>{t('recent_chats')}</SidebarGroupLabel>
{/* Search Input */}
{showSearch && (
<div className="px-2 pb-2">
<SidebarInput
placeholder="Search chats..."
placeholder={t('search_chats')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
@ -168,7 +170,7 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
<SidebarMenuItem>
<SidebarMenuButton disabled className="text-muted-foreground">
<Search className="h-4 w-4" />
<span>{searchQuery ? "No chats found" : "No recent chats"}</span>
<span>{searchQuery ? t('no_chats_found') : t('no_recent_chats')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
@ -178,7 +180,7 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
<SidebarMenuItem>
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
<MoreHorizontal />
<span>View All Chats</span>
<span>{t('view_all_chats')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}

View file

@ -3,6 +3,7 @@
import type { LucideIcon } from "lucide-react";
import type * as React from "react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import {
SidebarGroup,
@ -24,12 +25,14 @@ export function NavSecondary({
}: {
items: NavSecondaryItem[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const t = useTranslations('sidebar');
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
return (
<SidebarGroup {...props}>
<SidebarGroupLabel>SearchSpace</SidebarGroupLabel>
<SidebarGroupLabel>{t('search_space')}</SidebarGroupLabel>
<SidebarMenu>
{memoizedItems.map((item, index) => (
<SidebarMenuItem key={`${item.title}-${index}`}>