feat: localization service and frontend integration
- Add localization-service microservice (locale resolution, translations) - Add frontend API routes /api/locale and /api/translations/[locale] - Add LocalizationProvider and localization context - Integrate localization into layout, EmptyChat, MessageInput components - Update MICROSERVICES.md architecture docs - Add localization-service to workspaces Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
55
apps/frontend/src/app/api/locale/route.ts
Normal file
55
apps/frontend/src/app/api/locale/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const LOCALIZATION_SERVICE_URL =
|
||||
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
|
||||
|
||||
const fallbackLocale = {
|
||||
locale: 'en',
|
||||
language: 'en',
|
||||
region: null,
|
||||
countryCode: null,
|
||||
timezone: null,
|
||||
source: 'fallback' as const,
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const res = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, {
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
|
||||
'x-real-ip': req.headers.get('x-real-ip') ?? '',
|
||||
'user-agent': req.headers.get('user-agent') ?? '',
|
||||
'accept-language': req.headers.get('accept-language') ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) return NextResponse.json(fallbackLocale);
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(fallbackLocale);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const res = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
|
||||
'x-real-ip': req.headers.get('x-real-ip') ?? '',
|
||||
'user-agent': req.headers.get('user-agent') ?? '',
|
||||
'accept-language': req.headers.get('accept-language') ?? '',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) return NextResponse.json(fallbackLocale);
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(fallbackLocale);
|
||||
}
|
||||
}
|
||||
37
apps/frontend/src/app/api/translations/[locale]/route.ts
Normal file
37
apps/frontend/src/app/api/translations/[locale]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const LOCALIZATION_SERVICE_URL =
|
||||
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
try {
|
||||
const { locale } = await params;
|
||||
if (!locale) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Locale is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${LOCALIZATION_SERVICE_URL}/api/translations/${encodeURIComponent(locale)}`,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch translations' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch translations' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { Toaster } from 'sonner';
|
||||
import ThemeProvider from '@/components/theme/Provider';
|
||||
import { LocalizationProvider } from '@/lib/localization/context';
|
||||
import configManager from '@/lib/config';
|
||||
import SetupWizard from '@/components/Setup/SetupWizard';
|
||||
import { ChatProvider } from '@/lib/hooks/useChat';
|
||||
@@ -36,8 +37,9 @@ export default function RootLayout({
|
||||
<html className="h-full" lang="en" suppressHydrationWarning>
|
||||
<body className={cn('h-full antialiased', roboto.className)}>
|
||||
<ThemeProvider>
|
||||
{setupComplete ? (
|
||||
<ChatProvider>
|
||||
<LocalizationProvider>
|
||||
{setupComplete ? (
|
||||
<ChatProvider>
|
||||
<Sidebar>{children}</Sidebar>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
@@ -48,10 +50,11 @@ export default function RootLayout({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ChatProvider>
|
||||
) : (
|
||||
<SetupWizard configSections={configSections} />
|
||||
)}
|
||||
</ChatProvider>
|
||||
) : (
|
||||
<SetupWizard configSections={configSections} />
|
||||
)}
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import EmptyChatMessageInput from './EmptyChatMessageInput';
|
||||
import { File } from './ChatWindow';
|
||||
import Link from 'next/link';
|
||||
import WeatherWidget from './WeatherWidget';
|
||||
import NewsArticleWidget from './NewsArticleWidget';
|
||||
import SettingsButtonMobile from '@/components/Settings/SettingsButtonMobile';
|
||||
@@ -12,8 +9,10 @@ import {
|
||||
getShowNewsWidget,
|
||||
getShowWeatherWidget,
|
||||
} from '@/lib/config/clientRegistry';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
|
||||
const EmptyChat = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showWeather, setShowWeather] = useState(() =>
|
||||
typeof window !== 'undefined' ? getShowWeatherWidget() : true,
|
||||
);
|
||||
@@ -64,7 +63,7 @@ const EmptyChat = () => {
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium">
|
||||
Research begins here.
|
||||
{t('empty.subtitle')}
|
||||
</h2>
|
||||
<EmptyChatMessageInput />
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,12 @@ import Sources from './MessageInputActions/Sources';
|
||||
import Optimization from './MessageInputActions/Optimization';
|
||||
import Attach from './MessageInputActions/Attach';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
import ModelSelector from './MessageInputActions/ChatModelSelector';
|
||||
|
||||
const EmptyChatMessageInput = () => {
|
||||
const { sendMessage } = useChat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/* const [copilotEnabled, setCopilotEnabled] = useState(false); */
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -62,7 +64,7 @@ const EmptyChatMessageInput = () => {
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
minRows={2}
|
||||
className="px-2 bg-transparent placeholder:text-[15px] placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
|
||||
placeholder="Ask anything..."
|
||||
placeholder={t('input.askAnything')}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between mt-4">
|
||||
<Optimization />
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import AttachSmall from './MessageInputActions/AttachSmall';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
|
||||
const MessageInput = () => {
|
||||
const { loading, sendMessage } = useChat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [copilotEnabled, setCopilotEnabled] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -74,7 +76,7 @@ const MessageInput = () => {
|
||||
setTextareaRows(Math.ceil(height / props.rowHeight));
|
||||
}}
|
||||
className="transition bg-transparent dark:placeholder:text-white/50 placeholder:text-sm text-sm dark:text-white resize-none focus:outline-none w-full px-2 max-h-24 lg:max-h-36 xl:max-h-48 flex-grow flex-shrink"
|
||||
placeholder="Ask a follow-up"
|
||||
placeholder={t('input.askFollowUp')}
|
||||
/>
|
||||
{mode === 'single' && (
|
||||
<button
|
||||
|
||||
75
apps/frontend/src/lib/localization.ts
Normal file
75
apps/frontend/src/lib/localization.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Клиент для Localization Service.
|
||||
* Получает locale на основе геопозиции (через geo-device-service) и переводы.
|
||||
*/
|
||||
|
||||
const LOCALE_API = '/api/locale';
|
||||
const TRANSLATIONS_API = '/api/translations';
|
||||
|
||||
export interface LocalizationContext {
|
||||
locale: string;
|
||||
language: string;
|
||||
region: string | null;
|
||||
countryCode: string | null;
|
||||
timezone: string | null;
|
||||
source: 'geo' | 'accept-language' | 'client' | 'fallback';
|
||||
}
|
||||
|
||||
const collectClientData = () => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const nav = navigator;
|
||||
return {
|
||||
client: {
|
||||
language: nav.language,
|
||||
languages: nav.languages ? [...nav.languages] : undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Получить locale (серверный вызов — без client data).
|
||||
*/
|
||||
export async function fetchLocale(): Promise<LocalizationContext> {
|
||||
const res = await fetch(LOCALE_API);
|
||||
if (!res.ok) throw new Error('Failed to fetch locale');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить locale с данными клиента (браузер language).
|
||||
*/
|
||||
export async function fetchLocaleWithClient(): Promise<LocalizationContext> {
|
||||
const clientData = collectClientData();
|
||||
const res = await fetch(LOCALE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(clientData),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch locale');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить переводы для locale.
|
||||
*/
|
||||
export async function fetchTranslations(
|
||||
locale: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const res = await fetch(
|
||||
`${TRANSLATIONS_API}/${encodeURIComponent(locale)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch translations');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить locale и переводы одним вызовом.
|
||||
*/
|
||||
export async function fetchLocalization(): Promise<{
|
||||
locale: LocalizationContext;
|
||||
translations: Record<string, string>;
|
||||
}> {
|
||||
const locale = await fetchLocaleWithClient();
|
||||
const translations = await fetchTranslations(locale.locale);
|
||||
return { locale, translations };
|
||||
}
|
||||
133
apps/frontend/src/lib/localization/context.tsx
Normal file
133
apps/frontend/src/lib/localization/context.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
fetchLocaleWithClient,
|
||||
fetchTranslations,
|
||||
type LocalizationContext as LocaleContext,
|
||||
} from '@/lib/localization';
|
||||
|
||||
type Translations = Record<string, string>;
|
||||
|
||||
interface LocalizationState {
|
||||
locale: LocaleContext | null;
|
||||
translations: Translations | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
interface LocalizationContextValue extends LocalizationState {
|
||||
t: (key: string) => string;
|
||||
localeCode: string;
|
||||
}
|
||||
|
||||
const defaultTranslations: Translations = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Search...',
|
||||
'empty.subtitle': 'Research begins here.',
|
||||
'input.askAnything': 'Ask anything...',
|
||||
'input.askFollowUp': 'Ask a follow-up',
|
||||
'chat.send': 'Send',
|
||||
'chat.newChat': 'New Chat',
|
||||
'chat.suggestions': 'Suggestions',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error',
|
||||
'common.retry': 'Retry',
|
||||
};
|
||||
|
||||
const LocalizationContext = createContext<LocalizationContextValue | null>(null);
|
||||
|
||||
export function LocalizationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [state, setState] = useState<LocalizationState>({
|
||||
locale: null,
|
||||
translations: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const loc = await fetchLocaleWithClient();
|
||||
if (cancelled) return;
|
||||
const trans = await fetchTranslations(loc.locale);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
locale: loc,
|
||||
translations: trans,
|
||||
loading: false,
|
||||
error: false,
|
||||
});
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = loc.locale;
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
locale: null,
|
||||
translations: null,
|
||||
loading: false,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: string): string => {
|
||||
if (state.translations && key in state.translations) {
|
||||
return state.translations[key] ?? key;
|
||||
}
|
||||
return defaultTranslations[key] ?? key;
|
||||
},
|
||||
[state.translations],
|
||||
);
|
||||
|
||||
const localeCode = state.locale?.locale ?? 'en';
|
||||
|
||||
const value = useMemo<LocalizationContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
t,
|
||||
localeCode,
|
||||
}),
|
||||
[state, t, localeCode],
|
||||
);
|
||||
|
||||
return (
|
||||
<LocalizationContext.Provider value={value}>
|
||||
{children}
|
||||
</LocalizationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation(): LocalizationContextValue {
|
||||
const ctx = useContext(LocalizationContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
locale: null,
|
||||
translations: null,
|
||||
loading: false,
|
||||
error: true,
|
||||
t: (key: string) => defaultTranslations[key] ?? key,
|
||||
localeCode: 'en',
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user