From 6044a437f8318a3776830dbb4ef059f9aa4c2399 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 26 Nov 2025 21:15:17 -0300 Subject: [PATCH] Integrar LibreTranslate para traducao automatica --- docker-compose-dev.yml | 1 + docker-compose.yml | 1 + frontend/src/app/api/translate/route.ts | 112 +++++++++++++++++ frontend/src/components/TranslatedText.tsx | 134 +++++++++++++++++++++ frontend/src/hooks/useTranslate.ts | 109 +++++++++++++++++ 5 files changed, 357 insertions(+) create mode 100644 frontend/src/app/api/translate/route.ts create mode 100644 frontend/src/components/TranslatedText.tsx create mode 100644 frontend/src/hooks/useTranslate.ts diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 402d73d..d1d6fd6 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -51,6 +51,7 @@ services: - MINIO_SECRET_KEY=adminpassword - MINIO_BUCKET_NAME=occto-images-dev - JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_change_in_production_1234567890} + - LIBRETRANSLATE_URL=https://libretranslate.stackbyte.cloud depends_on: postgres_dev: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 35f6038..11dec00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ services: - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-adminpassword} - MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME:-occto-images} - JWT_SECRET=${JWT_SECRET:-b33500bb3dc5504535c34cc5f79f4ca0f60994b093bded14d48f76c0c090f032234693219e60398cab053a9c55c1d426ef7b1768104db9040254ba7db452f708} + - LIBRETRANSLATE_URL=${LIBRETRANSLATE_URL:-https://libretranslate.stackbyte.cloud} depends_on: postgres: condition: service_healthy diff --git a/frontend/src/app/api/translate/route.ts b/frontend/src/app/api/translate/route.ts new file mode 100644 index 0000000..9d5d66a --- /dev/null +++ b/frontend/src/app/api/translate/route.ts @@ -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(); +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 }); + } +} diff --git a/frontend/src/components/TranslatedText.tsx b/frontend/src/components/TranslatedText.tsx new file mode 100644 index 0000000..9a7cd32 --- /dev/null +++ b/frontend/src/components/TranslatedText.tsx @@ -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 ( + + {translatedText} + + ); +} + +/** + * Hook para traduzir objetos de conteúdo do banco de dados + */ +export function useTranslatedContent>(content: T): { + translatedContent: T; + isTranslating: boolean +} { + const { translateBatch, language } = useTranslate(); + const [translatedContent, setTranslatedContent] = useState(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 = newContent; + + for (let i = 0; i < parts.length - 1; i++) { + current = current[parts[i]] as Record; + } + + 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 }; +} diff --git a/frontend/src/hooks/useTranslate.ts b/frontend/src/hooks/useTranslate.ts new file mode 100644 index 0000000..4aef232 --- /dev/null +++ b/frontend/src/hooks/useTranslate.ts @@ -0,0 +1,109 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +// Cache local no cliente +const clientCache = new Map(); + +export function useTranslate() { + const { language } = useLanguage(); + const [isTranslating, setIsTranslating] = useState(false); + + const translate = useCallback(async (text: string): Promise => { + 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 => { + 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 }; +}