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

@ -3,6 +3,7 @@
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import React, { useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import {
AlertDialog,
AlertDialogAction,
@ -55,6 +56,7 @@ export function DocumentsFilters({
columnVisibility: ColumnVisibility;
onToggleColumn: (id: keyof ColumnVisibility, checked: boolean) => void;
}) {
const t = useTranslations('documents');
const id = React.useId();
const inputRef = useRef<HTMLInputElement>(null);
@ -90,9 +92,9 @@ export function DocumentsFilters({
className="peer min-w-60 ps-9"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
placeholder="Filter by title..."
placeholder={t('filter_placeholder')}
type="text"
aria-label="Filter by title"
aria-label={t('filter_placeholder')}
/>
<motion.div
className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"

View file

@ -3,6 +3,7 @@
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
import { motion } from "motion/react";
import React from "react";
import { useTranslations } from "next-intl";
import { DocumentViewer } from "@/components/document-viewer";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -66,6 +67,7 @@ export function DocumentsTableShell({
sortDesc: boolean;
onSortChange: (key: SortKey) => void;
}) {
const t = useTranslations('documents');
const sorted = React.useMemo(
() => sortDocuments(documents, sortKey, sortDesc),
[documents, sortKey, sortDesc]
@ -101,15 +103,15 @@ export function DocumentsTableShell({
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">Loading documents...</p>
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</div>
</div>
) : error ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-destructive">Error loading documents</p>
<p className="text-sm text-destructive">{t('error_loading')}</p>
<Button variant="outline" size="sm" onClick={() => onRefresh()} className="mt-2">
Retry
{t('retry')}
</Button>
</div>
</div>
@ -117,7 +119,7 @@ export function DocumentsTableShell({
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<FileX className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No documents found</p>
<p className="text-sm text-muted-foreground">{t('no_documents')}</p>
</div>
</div>
) : (
@ -140,7 +142,7 @@ export function DocumentsTableShell({
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("title")}
>
Title
{t('title')}
{sortKey === "title" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
@ -158,7 +160,7 @@ export function DocumentsTableShell({
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("document_type")}
>
Type
{t('type')}
{sortKey === "document_type" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
@ -170,7 +172,7 @@ export function DocumentsTableShell({
</TableHead>
)}
{columnVisibility.content && (
<TableHead style={{ width: 300 }}>Content Summary</TableHead>
<TableHead style={{ width: 300 }}>{t('content_summary')}</TableHead>
)}
{columnVisibility.created_at && (
<TableHead style={{ width: 120 }}>
@ -264,7 +266,7 @@ export function DocumentsTableShell({
content={doc.content}
trigger={
<Button variant="ghost" size="sm" className="w-fit text-xs">
View Full Content
{t('view_full')}
</Button>
}
/>
@ -335,7 +337,7 @@ export function DocumentsTableShell({
size="sm"
className="w-fit text-xs p-0 h-auto"
>
View Full Content
{t('view_full')}
</Button>
}
/>

View file

@ -2,6 +2,7 @@
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination";
@ -38,6 +39,7 @@ export function PaginationControls({
canNext: boolean;
id: string;
}) {
const t = useTranslations('documents');
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, total);
@ -50,7 +52,7 @@ export function PaginationControls({
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<Label htmlFor={id} className="max-sm:sr-only">
Rows per page
{t('rows_per_page')}
</Label>
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
<SelectTrigger id={id} className="w-fit whitespace-nowrap">

View file

@ -4,6 +4,7 @@ import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { useDocuments } from "@/hooks/use-documents";
@ -22,6 +23,7 @@ function useDebounced<T>(value: T, delay = 250) {
}
export default function DocumentsTable() {
const t = useTranslations('documents');
const id = useId();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
@ -120,21 +122,21 @@ export default function DocumentsTable() {
const onBulkDelete = async () => {
if (selectedIds.size === 0) {
toast.error("No rows selected");
toast.error(t('no_rows_selected'));
return;
}
try {
const results = await Promise.all(Array.from(selectedIds).map((id) => deleteDocument?.(id)));
const okCount = results.filter((r) => r === true).length;
if (okCount === selectedIds.size)
toast.success(`Successfully deleted ${okCount} document(s)`);
else toast.error("Some documents could not be deleted");
toast.success(t('delete_success_count', { count: okCount }));
else toast.error(t('delete_partial_failed'));
// Refetch the current page with appropriate method
await refreshCurrentView();
setSelectedIds(new Set());
} catch (e) {
console.error(e);
toast.error("Error deleting documents");
toast.error(t('delete_error'));
}
};

View file

@ -6,6 +6,7 @@ import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -39,6 +40,7 @@ function GridPattern() {
}
export default function FileUploader() {
const t = useTranslations('upload_documents');
const params = useParams();
const search_space_id = params.search_space_id as string;
@ -274,16 +276,16 @@ export default function FileUploader() {
await response.json();
toast("Upload Task Initiated", {
description: "Files Uploading Initiated",
toast(t('upload_initiated'), {
description: t('upload_initiated_desc'),
});
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setIsUploading(false);
setUploadProgress(0);
toast("Upload Error", {
description: `Error uploading files: ${error.message}`,
toast(t('upload_error'), {
description: `${t('upload_error_desc')}: ${error.message}`,
});
}
};
@ -330,19 +332,17 @@ export default function FileUploader() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload Documents
{t('title')}
</CardTitle>
<CardDescription>
Upload your files to make them searchable and accessible through AI-powered
conversations.
{t('subtitle')}
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Maximum file size: 50MB per file. Supported formats vary based on your ETL service
configuration.
{t('file_size_limit')}
</AlertDescription>
</Alert>
</CardContent>
@ -371,7 +371,7 @@ export default function FileUploader() {
className="flex flex-col items-center gap-4"
>
<Upload className="h-12 w-12 text-primary" />
<p className="text-lg font-medium text-primary">Drop files here</p>
<p className="text-lg font-medium text-primary">{t('drop_files')}</p>
</motion.div>
) : (
<motion.div
@ -381,8 +381,8 @@ export default function FileUploader() {
>
<Upload className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-lg font-medium">Drag & drop files here</p>
<p className="text-sm text-muted-foreground mt-1">or click to browse</p>
<p className="text-lg font-medium">{t('drag_drop')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('or_browse')}</p>
</div>
</motion.div>
)}
@ -400,7 +400,7 @@ export default function FileUploader() {
if (input) input.click();
}}
>
Browse Files
{t('browse_files')}
</Button>
</div>
</div>
@ -422,9 +422,9 @@ export default function FileUploader() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Selected Files ({files.length})</CardTitle>
<CardTitle>{t('selected_files', { count: files.length })}</CardTitle>
<CardDescription>
Total size: {formatFileSize(getTotalFileSize())}
{t('total_size')}: {formatFileSize(getTotalFileSize())}
</CardDescription>
</div>
<Button
@ -433,7 +433,7 @@ export default function FileUploader() {
onClick={() => setFiles([])}
disabled={isUploading}
>
Clear all
{t('clear_all')}
</Button>
</div>
</CardHeader>
@ -490,7 +490,7 @@ export default function FileUploader() {
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Uploading files...</span>
<span>{t('uploading_files')}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
@ -521,7 +521,7 @@ export default function FileUploader() {
>
<Upload className="h-5 w-5" />
</motion.div>
<span>Uploading...</span>
<span>{t('uploading')}</span>
</motion.div>
) : (
<motion.div
@ -531,7 +531,7 @@ export default function FileUploader() {
>
<CheckCircle2 className="h-5 w-5" />
<span>
Upload {files.length} {files.length === 1 ? "file" : "files"}
{t('upload_button', { count: files.length })}
</span>
</motion.div>
)}
@ -549,10 +549,10 @@ export default function FileUploader() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
Supported File Types
{t('supported_file_types')}
</CardTitle>
<CardDescription>
These file types are supported based on your current ETL service configuration.
{t('file_types_desc')}
</CardDescription>
</CardHeader>
<CardContent>

View file

@ -5,6 +5,7 @@ import { Globe, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Card,
@ -20,6 +21,7 @@ import { Label } from "@/components/ui/label";
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
export default function WebpageCrawler() {
const t = useTranslations('add_webpage');
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
@ -38,14 +40,14 @@ export default function WebpageCrawler() {
const handleSubmit = async () => {
// Validate that we have at least one URL
if (urlTags.length === 0) {
setError("Please add at least one URL");
setError(t('error_no_url'));
return;
}
// Validate all URLs
const invalidUrls = urlTags.filter((tag) => !isValidUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
setError(t('error_invalid_urls', { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
return;
}
@ -53,8 +55,8 @@ export default function WebpageCrawler() {
setIsSubmitting(true);
try {
toast("URL Crawling", {
description: "Starting URL crawling process...",
toast(t('crawling_toast'), {
description: t('crawling_toast_desc'),
});
// Extract URLs from tags
@ -83,16 +85,16 @@ export default function WebpageCrawler() {
await response.json();
toast("Crawling Successful", {
description: "URLs have been submitted for crawling",
toast(t('success_toast'), {
description: t('success_toast_desc'),
});
// Redirect to documents page
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setError(error.message || "An error occurred while crawling URLs");
toast("Crawling Error", {
description: `Error crawling URLs: ${error.message}`,
setError(error.message || t('error_generic'));
toast(t('error_toast'), {
description: `${t('error_toast_desc')}: ${error.message}`,
});
} finally {
setIsSubmitting(false);
@ -103,16 +105,16 @@ export default function WebpageCrawler() {
const handleAddTag = (text: string) => {
// Basic URL validation
if (!isValidUrl(text)) {
toast("Invalid URL", {
description: "Please enter a valid URL",
toast(t('invalid_url_toast'), {
description: t('invalid_url_toast_desc'),
});
return;
}
// Check for duplicates
if (urlTags.some((tag) => tag.text === text)) {
toast("Duplicate URL", {
description: "This URL has already been added",
toast(t('duplicate_url_toast'), {
description: t('duplicate_url_toast_desc'),
});
return;
}
@ -132,19 +134,19 @@ export default function WebpageCrawler() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Add Webpages for Crawling
{t('title')}
</CardTitle>
<CardDescription>Enter URLs to crawl and add to your document collection</CardDescription>
<CardDescription>{t('subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="url-input">Enter URLs to crawl</Label>
<Label htmlFor="url-input">{t('label')}</Label>
<TagInput
id="url-input"
tags={urlTags}
setTags={setUrlTags}
placeholder="Enter a URL and press Enter"
placeholder={t('placeholder')}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
@ -160,19 +162,19 @@ export default function WebpageCrawler() {
setActiveTagIndex={setActiveTagIndex}
/>
<p className="text-xs text-muted-foreground mt-1">
Add multiple URLs by pressing Enter after each one
{t('hint')}
</p>
</div>
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
<div className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
<h4 className="font-medium mb-2">{t('tips_title')}</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Enter complete URLs including http:// or https://</li>
<li>Make sure the websites allow crawling</li>
<li>Public webpages work best</li>
<li>Crawling may take some time depending on the website size</li>
<li>{t('tip_1')}</li>
<li>{t('tip_2')}</li>
<li>{t('tip_3')}</li>
<li>{t('tip_4')}</li>
</ul>
</div>
</div>
@ -182,16 +184,16 @@ export default function WebpageCrawler() {
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
{t('cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting || urlTags.length === 0}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
{t('submitting')}
</>
) : (
"Submit URLs for Crawling"
t('submit')
)}
</Button>
</CardFooter>

View file

@ -7,6 +7,7 @@ import { motion, type Variants } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Card,
@ -23,6 +24,7 @@ const youtubeRegex =
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
export default function YouTubeVideoAdder() {
const t = useTranslations('add_youtube');
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
@ -47,14 +49,14 @@ export default function YouTubeVideoAdder() {
const handleSubmit = async () => {
// Validate that we have at least one video URL
if (videoTags.length === 0) {
setError("Please add at least one YouTube video URL");
setError(t('error_no_video'));
return;
}
// Validate all URLs
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid YouTube URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
setError(t('error_invalid_urls', { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
return;
}
@ -62,8 +64,8 @@ export default function YouTubeVideoAdder() {
setIsSubmitting(true);
try {
toast("YouTube Video Processing", {
description: "Starting YouTube video processing...",
toast(t('processing_toast'), {
description: t('processing_toast_desc'),
});
// Extract URLs from tags
@ -92,16 +94,16 @@ export default function YouTubeVideoAdder() {
await response.json();
toast("Processing Successful", {
description: "YouTube videos have been submitted for processing",
toast(t('success_toast'), {
description: t('success_toast_desc'),
});
// Redirect to documents page
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setError(error.message || "An error occurred while processing YouTube videos");
toast("Processing Error", {
description: `Error processing YouTube videos: ${error.message}`,
setError(error.message || t('error_generic'));
toast(t('error_toast'), {
description: `${t('error_toast_desc')}: ${error.message}`,
});
} finally {
setIsSubmitting(false);
@ -112,16 +114,16 @@ export default function YouTubeVideoAdder() {
const handleAddTag = (text: string) => {
// Basic URL validation
if (!isValidYoutubeUrl(text)) {
toast("Invalid YouTube URL", {
description: "Please enter a valid YouTube video URL",
toast(t('invalid_url_toast'), {
description: t('invalid_url_toast_desc'),
});
return;
}
// Check for duplicates
if (videoTags.some((tag) => tag.text === text)) {
toast("Duplicate URL", {
description: "This YouTube video has already been added",
toast(t('duplicate_url_toast'), {
description: t('duplicate_url_toast_desc'),
});
return;
}
@ -167,10 +169,10 @@ export default function YouTubeVideoAdder() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconBrandYoutube className="h-5 w-5" />
Add YouTube Videos
{t('title')}
</CardTitle>
<CardDescription>
Enter YouTube video URLs to add to your document collection
{t('subtitle')}
</CardDescription>
</CardHeader>
</motion.div>
@ -179,12 +181,12 @@ export default function YouTubeVideoAdder() {
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
<Label htmlFor="video-input">{t('label')}</Label>
<TagInput
id="video-input"
tags={videoTags}
setTags={setVideoTags}
placeholder="Enter a YouTube URL and press Enter"
placeholder={t('placeholder')}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
@ -200,7 +202,7 @@ export default function YouTubeVideoAdder() {
setActiveTagIndex={setActiveTagIndex}
/>
<p className="text-xs text-muted-foreground mt-1">
Add multiple YouTube URLs by pressing Enter after each one
{t('hint')}
</p>
</div>
@ -216,18 +218,18 @@ export default function YouTubeVideoAdder() {
)}
<motion.div variants={itemVariants} className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
<h4 className="font-medium mb-2">{t('tips_title')}</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
<li>Make sure videos are publicly accessible</li>
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
<li>Processing may take some time depending on video length</li>
<li>{t('tip_1')}</li>
<li>{t('tip_2')}</li>
<li>{t('tip_3')}</li>
<li>{t('tip_4')}</li>
</ul>
</motion.div>
{videoTags.length > 0 && (
<motion.div variants={itemVariants} className="mt-4 space-y-2">
<h4 className="font-medium">Preview:</h4>
<h4 className="font-medium">{t('preview')}:</h4>
<div className="grid grid-cols-1 gap-3">
{videoTags.map((tag, index) => {
const videoId = extractVideoId(tag.text);
@ -263,7 +265,7 @@ export default function YouTubeVideoAdder() {
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
{t('cancel')}
</Button>
<Button
onClick={handleSubmit}
@ -273,7 +275,7 @@ export default function YouTubeVideoAdder() {
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
{t('processing')}
</>
) : (
<>
@ -285,7 +287,7 @@ export default function YouTubeVideoAdder() {
>
<IconBrandYoutube className="h-4 w-4" />
</motion.span>
Submit YouTube Videos
{t('submit')}
</>
)}
<motion.div