mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-22 21:28:12 +02:00
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:
parent
8aeaf419d0
commit
f58c7e4602
37 changed files with 2267 additions and 542 deletions
56
surfsense_web/components/LanguageSwitcher.tsx
Normal file
56
surfsense_web/components/LanguageSwitcher.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use client';
|
||||
|
||||
import {useLocaleContext} from '@/contexts/LocaleContext';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {Globe} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Language switcher component
|
||||
* Allows users to change the application language
|
||||
* Persists preference in localStorage
|
||||
*/
|
||||
export function LanguageSwitcher() {
|
||||
const {locale, setLocale} = useLocaleContext();
|
||||
|
||||
// Supported languages configuration
|
||||
const languages = [
|
||||
{code: 'en' as const, name: 'English', flag: '🇺🇸'},
|
||||
{code: 'zh' as const, name: '简体中文', flag: '🇨🇳'},
|
||||
];
|
||||
|
||||
/**
|
||||
* Handle language change
|
||||
* Updates locale in context and localStorage
|
||||
*/
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
setLocale(newLocale as 'en' | 'zh');
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={locale} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
<SelectValue>
|
||||
{languages.find(lang => lang.code === locale)?.name || 'English'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((language) => (
|
||||
<SelectItem key={language.code} value={language.code}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{language.flag}</span>
|
||||
<span>{language.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
|
|
@ -17,6 +18,7 @@ interface BreadcrumbItemInterface {
|
|||
}
|
||||
|
||||
export function DashboardBreadcrumb() {
|
||||
const t = useTranslations('breadcrumb');
|
||||
const pathname = usePathname();
|
||||
|
||||
// Parse the pathname to create breadcrumb items
|
||||
|
|
@ -25,11 +27,11 @@ export function DashboardBreadcrumb() {
|
|||
const breadcrumbs: BreadcrumbItemInterface[] = [];
|
||||
|
||||
// Always start with Dashboard
|
||||
breadcrumbs.push({ label: "Dashboard", href: "/dashboard" });
|
||||
breadcrumbs.push({ label: t('dashboard'), href: "/dashboard" });
|
||||
|
||||
// Handle search space
|
||||
if (segments[0] === "dashboard" && segments[1]) {
|
||||
breadcrumbs.push({ label: `Search Space ${segments[1]}`, href: `/dashboard/${segments[1]}` });
|
||||
breadcrumbs.push({ label: `${t('search_space')} ${segments[1]}`, href: `/dashboard/${segments[1]}` });
|
||||
|
||||
// Handle specific sections
|
||||
if (segments[2]) {
|
||||
|
|
@ -38,12 +40,13 @@ export function DashboardBreadcrumb() {
|
|||
|
||||
// Map section names to more readable labels
|
||||
const sectionLabels: Record<string, string> = {
|
||||
researcher: "Researcher",
|
||||
documents: "Documents",
|
||||
connectors: "Connectors",
|
||||
podcasts: "Podcasts",
|
||||
logs: "Logs",
|
||||
chats: "Chats",
|
||||
researcher: t('researcher'),
|
||||
documents: t('documents'),
|
||||
connectors: t('connectors'),
|
||||
podcasts: t('podcasts'),
|
||||
logs: t('logs'),
|
||||
chats: t('chats'),
|
||||
settings: t('settings'),
|
||||
};
|
||||
|
||||
sectionLabel = sectionLabels[section] || sectionLabel;
|
||||
|
|
@ -56,14 +59,14 @@ export function DashboardBreadcrumb() {
|
|||
// Handle documents sub-sections
|
||||
if (section === "documents") {
|
||||
const documentLabels: Record<string, string> = {
|
||||
upload: "Upload Documents",
|
||||
youtube: "Add YouTube Videos",
|
||||
webpage: "Add Webpages",
|
||||
upload: t('upload_documents'),
|
||||
youtube: t('add_youtube'),
|
||||
webpage: t('add_webpages'),
|
||||
};
|
||||
|
||||
const documentLabel = documentLabels[subSection] || subSectionLabel;
|
||||
breadcrumbs.push({
|
||||
label: "Documents",
|
||||
label: t('documents'),
|
||||
href: `/dashboard/${segments[1]}/documents`,
|
||||
});
|
||||
breadcrumbs.push({ label: documentLabel });
|
||||
|
|
@ -105,13 +108,13 @@ export function DashboardBreadcrumb() {
|
|||
}
|
||||
|
||||
const connectorLabels: Record<string, string> = {
|
||||
add: "Add Connector",
|
||||
manage: "Manage Connectors",
|
||||
add: t('add_connector'),
|
||||
manage: t('manage_connectors'),
|
||||
};
|
||||
|
||||
const connectorLabel = connectorLabels[subSection] || subSectionLabel;
|
||||
breadcrumbs.push({
|
||||
label: "Connectors",
|
||||
label: t('connectors'),
|
||||
href: `/dashboard/${segments[1]}/connectors`,
|
||||
});
|
||||
breadcrumbs.push({ label: connectorLabel });
|
||||
|
|
@ -120,12 +123,12 @@ export function DashboardBreadcrumb() {
|
|||
|
||||
// Handle other sub-sections
|
||||
const subSectionLabels: Record<string, string> = {
|
||||
upload: "Upload Documents",
|
||||
youtube: "Add YouTube Videos",
|
||||
webpage: "Add Webpages",
|
||||
add: "Add Connector",
|
||||
edit: "Edit Connector",
|
||||
manage: "Manage",
|
||||
upload: t('upload_documents'),
|
||||
youtube: t('add_youtube'),
|
||||
webpage: t('add_webpages'),
|
||||
add: t('add_connector'),
|
||||
edit: t('edit_connector'),
|
||||
manage: t('manage'),
|
||||
};
|
||||
|
||||
subSectionLabel = subSectionLabels[subSection] || subSectionLabel;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { AlertCircle, Bot, Plus, Trash2 } from "lucide-react";
|
|||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
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";
|
||||
|
|
@ -34,6 +35,7 @@ export function AddProviderStep({
|
|||
onConfigCreated,
|
||||
onConfigDeleted,
|
||||
}: AddProviderStepProps) {
|
||||
const t = useTranslations('onboard');
|
||||
const { llmConfigs, createLLMConfig, deleteLLMConfig } = useLLMConfigs(searchSpaceId);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateLLMConfig>({
|
||||
|
|
@ -94,15 +96,14 @@ export function AddProviderStep({
|
|||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Add at least one LLM provider to continue. You can configure multiple providers and choose
|
||||
specific roles for each one in the next step.
|
||||
{t('add_provider_instruction')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Existing Configurations */}
|
||||
{llmConfigs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Your LLM Configurations</h3>
|
||||
<h3 className="text-lg font-semibold">{t('your_llm_configs')}</h3>
|
||||
<div className="grid gap-4">
|
||||
{llmConfigs.map((config) => (
|
||||
<motion.div
|
||||
|
|
@ -121,9 +122,9 @@ export function AddProviderStep({
|
|||
<Badge variant="secondary">{config.provider}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model: {config.model_name}
|
||||
{config.language && ` • Language: ${config.language}`}
|
||||
{config.api_base && ` • Base: ${config.api_base}`}
|
||||
{t('model')}: {config.model_name}
|
||||
{config.language && ` • ${t('language')}: ${config.language}`}
|
||||
{config.api_base && ` • ${t('base')}: ${config.api_base}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -153,32 +154,32 @@ export function AddProviderStep({
|
|||
<Card className="border-dashed border-2 hover:border-primary/50 transition-colors">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Plus className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Add LLM Provider</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('add_provider_title')}</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Configure your first model provider to get started
|
||||
{t('add_provider_subtitle')}
|
||||
</p>
|
||||
<Button onClick={() => setIsAddingNew(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Provider
|
||||
{t('add_provider_button')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add New LLM Provider</CardTitle>
|
||||
<CardTitle>{t('add_new_llm_provider')}</CardTitle>
|
||||
<CardDescription>
|
||||
Configure a new language model provider for your AI assistant
|
||||
{t('configure_new_provider')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Configuration Name *</Label>
|
||||
<Label htmlFor="name">{t('config_name_required')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., My OpenAI GPT-4"
|
||||
placeholder={t('config_name_placeholder')}
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
required
|
||||
|
|
@ -186,13 +187,13 @@ export function AddProviderStep({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider">Provider *</Label>
|
||||
<Label htmlFor="provider">{t('provider_required')}</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(value) => handleInputChange("provider", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
<SelectValue placeholder={t('provider_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_PROVIDERS.map((provider) => (
|
||||
|
|
@ -206,13 +207,13 @@ export function AddProviderStep({
|
|||
|
||||
{/* language */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">Language (Optional)</Label>
|
||||
<Label htmlFor="language">{t('language_optional')}</Label>
|
||||
<Select
|
||||
value={formData.language || "English"}
|
||||
onValueChange={(value) => handleInputChange("language", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select language" />
|
||||
<SelectValue placeholder={t('language_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((language) => (
|
||||
|
|
@ -227,10 +228,10 @@ export function AddProviderStep({
|
|||
|
||||
{formData.provider === "CUSTOM" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom_provider">Custom Provider Name *</Label>
|
||||
<Label htmlFor="custom_provider">{t('custom_provider_name')}</Label>
|
||||
<Input
|
||||
id="custom_provider"
|
||||
placeholder="e.g., my-custom-provider"
|
||||
placeholder={t('custom_provider_placeholder')}
|
||||
value={formData.custom_provider}
|
||||
onChange={(e) => handleInputChange("custom_provider", e.target.value)}
|
||||
required
|
||||
|
|
@ -239,27 +240,27 @@ export function AddProviderStep({
|
|||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model_name">Model Name *</Label>
|
||||
<Label htmlFor="model_name">{t('model_name_required')}</Label>
|
||||
<Input
|
||||
id="model_name"
|
||||
placeholder={selectedProvider?.example || "e.g., gpt-4"}
|
||||
placeholder={selectedProvider?.example || t('model_name_placeholder')}
|
||||
value={formData.model_name}
|
||||
onChange={(e) => handleInputChange("model_name", e.target.value)}
|
||||
required
|
||||
/>
|
||||
{selectedProvider && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Examples: {selectedProvider.example}
|
||||
{t('examples')}: {selectedProvider.example}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_key">API Key *</Label>
|
||||
<Label htmlFor="api_key">{t('api_key_required')}</Label>
|
||||
<Input
|
||||
id="api_key"
|
||||
type="password"
|
||||
placeholder="Your API key"
|
||||
placeholder={t('api_key_placeholder')}
|
||||
value={formData.api_key}
|
||||
onChange={(e) => handleInputChange("api_key", e.target.value)}
|
||||
required
|
||||
|
|
@ -267,10 +268,10 @@ export function AddProviderStep({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_base">API Base URL (Optional)</Label>
|
||||
<Label htmlFor="api_base">{t('api_base_optional')}</Label>
|
||||
<Input
|
||||
id="api_base"
|
||||
placeholder="e.g., https://api.openai.com/v1"
|
||||
placeholder={t('api_base_placeholder')}
|
||||
value={formData.api_base}
|
||||
onChange={(e) => handleInputChange("api_base", e.target.value)}
|
||||
/>
|
||||
|
|
@ -286,7 +287,7 @@ export function AddProviderStep({
|
|||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Adding..." : "Add Provider"}
|
||||
{isSubmitting ? t('adding') : t('add_provider')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -294,7 +295,7 @@ export function AddProviderStep({
|
|||
onClick={() => setIsAddingNew(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { AlertCircle, Bot, Brain, CheckCircle, Zap } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -16,39 +17,40 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
|
||||
const ROLE_DESCRIPTIONS = {
|
||||
long_context: {
|
||||
icon: Brain,
|
||||
title: "Long Context LLM",
|
||||
description: "Handles complex tasks requiring extensive context understanding and reasoning",
|
||||
color: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
examples: "Document analysis, research synthesis, complex Q&A",
|
||||
},
|
||||
fast: {
|
||||
icon: Zap,
|
||||
title: "Fast LLM",
|
||||
description: "Optimized for quick responses and real-time interactions",
|
||||
color: "bg-green-100 text-green-800 border-green-200",
|
||||
examples: "Quick searches, simple questions, instant responses",
|
||||
},
|
||||
strategic: {
|
||||
icon: Bot,
|
||||
title: "Strategic LLM",
|
||||
description: "Advanced reasoning for planning and strategic decision making",
|
||||
color: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
examples: "Planning workflows, strategic analysis, complex problem solving",
|
||||
},
|
||||
};
|
||||
|
||||
interface AssignRolesStepProps {
|
||||
searchSpaceId: number;
|
||||
onPreferencesUpdated?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignRolesStepProps) {
|
||||
const t = useTranslations('onboard');
|
||||
const { llmConfigs } = useLLMConfigs(searchSpaceId);
|
||||
const { preferences, updatePreferences } = useLLMPreferences(searchSpaceId);
|
||||
|
||||
const ROLE_DESCRIPTIONS = {
|
||||
long_context: {
|
||||
icon: Brain,
|
||||
title: t('long_context_llm_title'),
|
||||
description: t('long_context_llm_desc'),
|
||||
color: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
examples: t('long_context_llm_examples'),
|
||||
},
|
||||
fast: {
|
||||
icon: Zap,
|
||||
title: t('fast_llm_title'),
|
||||
description: t('fast_llm_desc'),
|
||||
color: "bg-green-100 text-green-800 border-green-200",
|
||||
examples: t('fast_llm_examples'),
|
||||
},
|
||||
strategic: {
|
||||
icon: Bot,
|
||||
title: t('strategic_llm_title'),
|
||||
description: t('strategic_llm_desc'),
|
||||
color: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
examples: t('strategic_llm_examples'),
|
||||
},
|
||||
};
|
||||
|
||||
const [assignments, setAssignments] = useState({
|
||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||
fast_llm_id: preferences.fast_llm_id || "",
|
||||
|
|
@ -109,9 +111,9 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
|
|||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No LLM Configurations Found</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('no_llm_configs_found')}</h3>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Please add at least one LLM provider in the previous step before assigning roles.
|
||||
{t('add_provider_before_roles')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -123,8 +125,7 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
|
|||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Assign your LLM configurations to specific roles. Each role serves different purposes in
|
||||
your workflow.
|
||||
{t('assign_roles_instruction')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
@ -161,17 +162,17 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<strong>Use cases:</strong> {role.examples}
|
||||
<strong>{t('use_cases')}:</strong> {role.examples}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Assign LLM Configuration:</Label>
|
||||
<Label className="text-sm font-medium">{t('assign_llm_config')}:</Label>
|
||||
<Select
|
||||
value={currentAssignment?.toString() || ""}
|
||||
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an LLM configuration" />
|
||||
<SelectValue placeholder={t('select_llm_config')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{llmConfigs
|
||||
|
|
@ -195,12 +196,12 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
|
|||
<div className="mt-3 p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Bot className="w-4 h-4" />
|
||||
<span className="font-medium">Assigned:</span>
|
||||
<span className="font-medium">{t('assigned')}:</span>
|
||||
<Badge variant="secondary">{assignedConfig.provider}</Badge>
|
||||
<span>{assignedConfig.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Model: {assignedConfig.model_name}
|
||||
{t('model')}: {assignedConfig.model_name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -216,7 +217,7 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
|
|||
<div className="flex justify-center pt-4">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 text-green-700 rounded-lg border border-green-200">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">All roles assigned and saved!</span>
|
||||
<span className="text-sm font-medium">{t('all_roles_assigned_saved')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -224,7 +225,7 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
|
|||
{/* Progress Indicator */}
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Progress:</span>
|
||||
<span>{t('progress')}:</span>
|
||||
<div className="flex gap-1">
|
||||
{Object.keys(ROLE_DESCRIPTIONS).map((key, _index) => (
|
||||
<div
|
||||
|
|
@ -238,8 +239,10 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
|
|||
))}
|
||||
</div>
|
||||
<span>
|
||||
{Object.values(assignments).filter(Boolean).length} of{" "}
|
||||
{Object.keys(ROLE_DESCRIPTIONS).length} roles assigned
|
||||
{t('roles_assigned', {
|
||||
assigned: Object.values(assignments).filter(Boolean).length,
|
||||
total: Object.keys(ROLE_DESCRIPTIONS).length
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
19
surfsense_web/components/providers/I18nProvider.tsx
Normal file
19
surfsense_web/components/providers/I18nProvider.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleContext } from '@/contexts/LocaleContext';
|
||||
|
||||
/**
|
||||
* I18n Provider component
|
||||
* Wraps NextIntlClientProvider with dynamic locale and messages from LocaleContext
|
||||
*/
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const { locale, messages } = useLocaleContext();
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue