Integrar LibreTranslate para traducao automatica
This commit is contained in:
@@ -51,6 +51,7 @@ services:
|
|||||||
- MINIO_SECRET_KEY=adminpassword
|
- MINIO_SECRET_KEY=adminpassword
|
||||||
- MINIO_BUCKET_NAME=occto-images-dev
|
- MINIO_BUCKET_NAME=occto-images-dev
|
||||||
- JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_change_in_production_1234567890}
|
- JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_change_in_production_1234567890}
|
||||||
|
- LIBRETRANSLATE_URL=https://libretranslate.stackbyte.cloud
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres_dev:
|
postgres_dev:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ services:
|
|||||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-adminpassword}
|
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-adminpassword}
|
||||||
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME:-occto-images}
|
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME:-occto-images}
|
||||||
- JWT_SECRET=${JWT_SECRET:-b33500bb3dc5504535c34cc5f79f4ca0f60994b093bded14d48f76c0c090f032234693219e60398cab053a9c55c1d426ef7b1768104db9040254ba7db452f708}
|
- JWT_SECRET=${JWT_SECRET:-b33500bb3dc5504535c34cc5f79f4ca0f60994b093bded14d48f76c0c090f032234693219e60398cab053a9c55c1d426ef7b1768104db9040254ba7db452f708}
|
||||||
|
- LIBRETRANSLATE_URL=${LIBRETRANSLATE_URL:-https://libretranslate.stackbyte.cloud}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
112
frontend/src/app/api/translate/route.ts
Normal file
112
frontend/src/app/api/translate/route.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL || 'https://libretranslate.stackbyte.cloud';
|
||||||
|
|
||||||
|
// Cache simples em memória para traduções
|
||||||
|
const translationCache = new Map<string, { text: string; timestamp: number }>();
|
||||||
|
const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 horas
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { text, source = 'pt', target = 'en' } = await request.json();
|
||||||
|
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Texto é obrigatório' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se origem e destino são iguais, retorna o texto original
|
||||||
|
if (source === target) {
|
||||||
|
return NextResponse.json({ translatedText: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar cache
|
||||||
|
const cacheKey = `${source}:${target}:${text}`;
|
||||||
|
const cached = translationCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return NextResponse.json({ translatedText: cached.text, cached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chamar LibreTranslate
|
||||||
|
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
q: text,
|
||||||
|
source: source,
|
||||||
|
target: target,
|
||||||
|
format: 'text',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('LibreTranslate error:', await response.text());
|
||||||
|
return NextResponse.json({ translatedText: text }); // Fallback: retorna original
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const translatedText = data.translatedText || text;
|
||||||
|
|
||||||
|
// Salvar no cache
|
||||||
|
translationCache.set(cacheKey, { text: translatedText, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return NextResponse.json({ translatedText });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao traduzir' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint para traduzir múltiplos textos de uma vez
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { texts, source = 'pt', target = 'en' } = await request.json();
|
||||||
|
|
||||||
|
if (!texts || !Array.isArray(texts)) {
|
||||||
|
return NextResponse.json({ error: 'Array de textos é obrigatório' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === target) {
|
||||||
|
return NextResponse.json({ translations: texts });
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations = await Promise.all(
|
||||||
|
texts.map(async (text: string) => {
|
||||||
|
if (!text) return text;
|
||||||
|
|
||||||
|
const cacheKey = `${source}:${target}:${text}`;
|
||||||
|
const cached = translationCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ q: text, source, target, format: 'text' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const translatedText = data.translatedText || text;
|
||||||
|
translationCache.set(cacheKey, { text: translatedText, timestamp: Date.now() });
|
||||||
|
return translatedText;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Translation error for:', text, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text; // Fallback
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ translations });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Batch translation error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao traduzir' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
134
frontend/src/components/TranslatedText.tsx
Normal file
134
frontend/src/components/TranslatedText.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, ElementType, ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { useTranslate } from '@/hooks/useTranslate';
|
||||||
|
|
||||||
|
interface TranslatedTextProps {
|
||||||
|
text: string;
|
||||||
|
as?: 'span' | 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' | 'li';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente que traduz texto automaticamente via LibreTranslate
|
||||||
|
* quando o idioma não é português
|
||||||
|
*/
|
||||||
|
export function TranslatedText({ text, as = 'span', className }: TranslatedTextProps) {
|
||||||
|
const { translate, language } = useTranslate();
|
||||||
|
const [translatedText, setTranslatedText] = useState(text);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (language === 'PT') {
|
||||||
|
setTranslatedText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
translate(text).then((result) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTranslatedText(result);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [text, language, translate]);
|
||||||
|
|
||||||
|
const Tag = as;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag className={className} data-translating={isLoading}>
|
||||||
|
{translatedText}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para traduzir objetos de conteúdo do banco de dados
|
||||||
|
*/
|
||||||
|
export function useTranslatedContent<T extends Record<string, unknown>>(content: T): {
|
||||||
|
translatedContent: T;
|
||||||
|
isTranslating: boolean
|
||||||
|
} {
|
||||||
|
const { translateBatch, language } = useTranslate();
|
||||||
|
const [translatedContent, setTranslatedContent] = useState<T>(content);
|
||||||
|
const [isTranslating, setIsTranslating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (language === 'PT') {
|
||||||
|
setTranslatedContent(content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const translateContent = async () => {
|
||||||
|
setIsTranslating(true);
|
||||||
|
|
||||||
|
// Extrair todos os textos do objeto
|
||||||
|
const texts: string[] = [];
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
const extractTexts = (obj: unknown, path: string = '') => {
|
||||||
|
if (typeof obj === 'string' && obj.length > 0) {
|
||||||
|
texts.push(obj);
|
||||||
|
paths.push(path);
|
||||||
|
} else if (Array.isArray(obj)) {
|
||||||
|
obj.forEach((item, index) => extractTexts(item, `${path}[${index}]`));
|
||||||
|
} else if (obj && typeof obj === 'object') {
|
||||||
|
Object.entries(obj).forEach(([key, value]) => {
|
||||||
|
extractTexts(value, path ? `${path}.${key}` : key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
extractTexts(content);
|
||||||
|
|
||||||
|
if (texts.length === 0) {
|
||||||
|
setIsTranslating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const translations = await translateBatch(texts);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Reconstruir objeto com traduções
|
||||||
|
const newContent = JSON.parse(JSON.stringify(content));
|
||||||
|
|
||||||
|
paths.forEach((path, index) => {
|
||||||
|
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
|
||||||
|
let current: Record<string, unknown> = newContent;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
current = current[parts[i]] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
current[parts[parts.length - 1]] = translations[index];
|
||||||
|
});
|
||||||
|
|
||||||
|
setTranslatedContent(newContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsTranslating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
translateContent();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [content, language, translateBatch]);
|
||||||
|
|
||||||
|
return { translatedContent, isTranslating };
|
||||||
|
}
|
||||||
109
frontend/src/hooks/useTranslate.ts
Normal file
109
frontend/src/hooks/useTranslate.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
// Cache local no cliente
|
||||||
|
const clientCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function useTranslate() {
|
||||||
|
const { language } = useLanguage();
|
||||||
|
const [isTranslating, setIsTranslating] = useState(false);
|
||||||
|
|
||||||
|
const translate = useCallback(async (text: string): Promise<string> => {
|
||||||
|
if (!text || language === 'PT') return text;
|
||||||
|
|
||||||
|
const targetLang = language.toLowerCase(); // PT -> pt, EN -> en, ES -> es
|
||||||
|
const cacheKey = `pt:${targetLang}:${text}`;
|
||||||
|
|
||||||
|
// Verificar cache local
|
||||||
|
if (clientCache.has(cacheKey)) {
|
||||||
|
return clientCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsTranslating(true);
|
||||||
|
const response = await fetch('/api/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, source: 'pt', target: targetLang }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
clientCache.set(cacheKey, data.translatedText);
|
||||||
|
return data.translatedText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsTranslating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const translateBatch = useCallback(async (texts: string[]): Promise<string[]> => {
|
||||||
|
if (language === 'PT') return texts;
|
||||||
|
|
||||||
|
const targetLang = language.toLowerCase();
|
||||||
|
|
||||||
|
// Separar textos em cache e não em cache
|
||||||
|
const results: string[] = new Array(texts.length);
|
||||||
|
const toTranslate: { index: number; text: string }[] = [];
|
||||||
|
|
||||||
|
texts.forEach((text, index) => {
|
||||||
|
if (!text) {
|
||||||
|
results[index] = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `pt:${targetLang}:${text}`;
|
||||||
|
if (clientCache.has(cacheKey)) {
|
||||||
|
results[index] = clientCache.get(cacheKey)!;
|
||||||
|
} else {
|
||||||
|
toTranslate.push({ index, text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toTranslate.length === 0) return results;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsTranslating(true);
|
||||||
|
const response = await fetch('/api/translate', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
texts: toTranslate.map(t => t.text),
|
||||||
|
source: 'pt',
|
||||||
|
target: targetLang,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
toTranslate.forEach((item, i) => {
|
||||||
|
const translated = data.translations[i];
|
||||||
|
results[item.index] = translated;
|
||||||
|
clientCache.set(`pt:${targetLang}:${item.text}`, translated);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: usar texto original
|
||||||
|
toTranslate.forEach(item => {
|
||||||
|
results[item.index] = item.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Batch translation error:', error);
|
||||||
|
toTranslate.forEach(item => {
|
||||||
|
results[item.index] = item.text;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsTranslating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
return { translate, translateBatch, isTranslating, language };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user