From 70423474d22aead79d44fb85a91fd77ab12a836b Mon Sep 17 00:00:00 2001 From: Paulo Massingue Date: Wed, 11 Nov 2026 11:46:12 +0200 Subject: [PATCH] navbar fixa durante a rolagem --- .../Api/TranslationApiController.php | 33 ++++- resources/css/tempo-header.css | 3 +- resources/js/layouts/portal-navigation.tsx | 2 +- resources/js/translation/page-translator.tsx | 130 ++++++++++++++---- resources/js/translation/service.ts | 42 ++++++ tests/Feature/TranslationApiTest.php | 26 ++++ 6 files changed, 202 insertions(+), 34 deletions(-) diff --git a/app/Http/Controllers/Api/TranslationApiController.php b/app/Http/Controllers/Api/TranslationApiController.php index 7f4c8a59..7056ce73 100644 --- a/app/Http/Controllers/Api/TranslationApiController.php +++ b/app/Http/Controllers/Api/TranslationApiController.php @@ -26,13 +26,27 @@ class TranslationApiController extends Controller public function translate(Request $request): JsonResponse { $data = $request->validate([ - 'text' => 'required|string|max:10000', + 'text' => is_array($request->input('text')) + ? 'required|array|min:1|max:8' + : 'required|string|max:10000', + 'text.*' => 'required|string|max:10000', 'source_lang' => 'required|string|max:20', 'target_lang' => 'required|string|max:20', 'is_html' => 'boolean', ]); + + $isBatch = is_array($data['text']); + if ($isBatch && mb_strlen(implode('', $data['text'])) > 10000) { + return response()->json(['message' => 'O lote de tradução excede o limite permitido.'], 422); + } + if ($data['source_lang'] === $data['target_lang']) { - return response()->json(['original_text' => $data['text'], 'translated_text' => $data['text'], 'source_lang' => $data['source_lang'], 'target_lang' => $data['target_lang']])->header('Cache-Control', 'no-store'); + return response()->json([ + 'original_text' => $data['text'], + 'translated_text' => $data['text'], + 'source_lang' => $data['source_lang'], + 'target_lang' => $data['target_lang'], + ])->header('Cache-Control', 'no-store'); } $payload = ['q' => $data['text'], 'source' => $data['source_lang'], 'target' => $data['target_lang'], 'format' => ($data['is_html'] ?? false) ? 'html' : 'text']; @@ -42,8 +56,19 @@ class TranslationApiController extends Controller try { $response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->post($this->url().'/translate', $payload); $translated = $response->json('translatedText'); - if ($response->successful() && is_string($translated) && $translated !== '') { - return response()->json(['original_text' => $data['text'], 'translated_text' => $translated, 'source_lang' => $data['source_lang'], 'target_lang' => $data['target_lang']])->header('Cache-Control', 'no-store'); + $validTranslation = $isBatch + ? is_array($translated) + && count($translated) === count($data['text']) + && ! array_filter($translated, fn ($text) => ! is_string($text) || $text === '') + : is_string($translated) && $translated !== ''; + + if ($response->successful() && $validTranslation) { + return response()->json([ + 'original_text' => $data['text'], + 'translated_text' => $translated, + 'source_lang' => $data['source_lang'], + 'target_lang' => $data['target_lang'], + ])->header('Cache-Control', 'no-store'); } } catch (Throwable) { } diff --git a/resources/css/tempo-header.css b/resources/css/tempo-header.css index 8d57b441..ddb0fb79 100644 --- a/resources/css/tempo-header.css +++ b/resources/css/tempo-header.css @@ -1,6 +1,7 @@ /* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */ .tempo-header { - position: relative; + position: sticky; + top: 0; z-index: 40; background: #090909; color: #fff; diff --git a/resources/js/layouts/portal-navigation.tsx b/resources/js/layouts/portal-navigation.tsx index c31be9e1..2d4b2b14 100644 --- a/resources/js/layouts/portal-navigation.tsx +++ b/resources/js/layouts/portal-navigation.tsx @@ -127,7 +127,7 @@ function PortalNavigationContent({ children }: { children: ReactNode }) { )} { -
+
} diff --git a/resources/js/translation/page-translator.tsx b/resources/js/translation/page-translator.tsx index e317d860..4915cdb7 100644 --- a/resources/js/translation/page-translator.tsx +++ b/resources/js/translation/page-translator.tsx @@ -1,12 +1,16 @@ import { useEffect } from 'react'; import { useTempoUi } from '@/components/tempo-brand'; -import { translateText } from '@/translation/service'; +import { TranslationRequestError, translateTexts } from '@/translation/service'; import { protectTranslationTokens, restoreTranslationTokens } from '@/translation/utils'; // Never change the structure of React's DOM or the value of a form control. const excluded = 'script,style,noscript,code,pre,textarea,[contenteditable]:not([contenteditable="false"]),[translate="no"],[data-no-translate],.no-translate'; const attributes = ['placeholder', 'title', 'aria-label', 'alt'] as const; type Target = { node: Text | Element; attribute?: string; original: string; translated?: string }; +type QueuedText = { value: string; resolve: (value: string) => void; reject: (error: unknown) => void }; +const batchMaxItems = 8; +const batchMaxChars = 2200; +const requestMaxChars = 9000; function read(target: Target): string | null { return target.attribute ? (target.node as Element).getAttribute(target.attribute) : target.node.nodeValue; @@ -21,48 +25,111 @@ export function PageTranslator() { const { language } = useTempoUi(); useEffect(() => { + document.documentElement.lang = language; if (language === 'pt') return; let active = true; const abort = new AbortController(); const targets = new Map>(); const cache = new Map>(); - const queue: Array<() => void> = []; + const queue: QueuedText[] = []; let running = 0; let timer: ReturnType | undefined; let unavailable = false; + let failureReported = false; - const run = () => { + const schedule = (delay = 0) => { if (!active || unavailable || timer || running >= 2 || !queue.length) return; - running++; - queue.shift()?.(); - // Stay below the API's per-minute limit on large pages. - timer = setTimeout(() => { timer = undefined; run(); }, 650); + timer = setTimeout(() => { + timer = undefined; + void flush(); + }, delay); }; + + const rejectQueued = (error: unknown) => { + if (timer) clearTimeout(timer); + timer = undefined; + queue.splice(0).forEach((item) => item.reject(error)); + }; + + const enqueue = (value: string): Promise => + new Promise((resolve, reject) => { + queue.push({ value, resolve, reject }); + schedule(); + }); + + const flush = async () => { + if (!active || unavailable || running >= 2 || !queue.length) return; + + const batch: QueuedText[] = []; + let batchLength = 0; + while (queue.length && batch.length < batchMaxItems) { + const next = queue[0]; + if (batch.length > 0 && batchLength + next.value.length > batchMaxChars) break; + batch.push(queue.shift()!); + batchLength += next.value.length; + } + + running++; + schedule(650); + try { + const translated = await translateTexts( + batch.map((item) => item.value), + 'auto', + language, + abort.signal, + ); + batch.forEach((item, index) => item.resolve(translated[index])); + } catch (error) { + const isUnavailable = !abort.signal.aborted + && error instanceof TranslationRequestError + && (error.status === 503 || error.status === null); + if (isUnavailable) { + unavailable = true; + rejectQueued(error); + } + batch.forEach((item) => item.reject(error)); + } finally { + running--; + schedule(650); + } + }; + + const splitForRequests = (value: string): string[] => { + const chunks: string[] = []; + let start = 0; + while (start < value.length) { + let end = Math.min(start + requestMaxChars, value.length); + if (end < value.length) { + const boundary = value.lastIndexOf(' ', end); + if (boundary > start) end = boundary + 1; + } + chunks.push(value.slice(start, end)); + start = end; + } + return chunks; + }; + const translate = (value: string): Promise => { const previous = cache.get(value); if (previous) return previous; - const promise = new Promise((resolve, reject) => { - queue.push(() => { - const protectedText = protectTranslationTokens(value); - translateText({ text: protectedText.text, sourceLanguage: 'auto', targetLanguage: language }, abort.signal) - .then(({ translatedText }) => { - for (const token of protectedText.replacements.keys()) { - if (!translatedText.includes(token)) throw new Error('Translation lost a protected token'); - } - resolve(restoreTranslationTokens(translatedText, protectedText.replacements)); - }) - .catch((error) => { - if (!abort.signal.aborted && (error?.status === 503 || error?.status === null)) unavailable = true; - reject(error); - }) - .finally(() => { running--; run(); }); - }); - run(); + + const protectedText = protectTranslationTokens(value); + const promise = Promise.all( + splitForRequests(protectedText.text).map(enqueue), + ).then((translatedChunks) => { + const translatedText = translatedChunks.join(''); + for (const token of protectedText.replacements.keys()) { + if (!translatedText.includes(token)) { + throw new Error('Translation lost a protected token'); + } + } + return restoreTranslationTokens(translatedText, protectedText.replacements); }); cache.set(value, promise); promise.catch(() => cache.delete(value)); return promise; }; + const consider = (node: Text | Element, attribute?: string) => { const element = node instanceof Element ? node : node.parentElement; if (!element || element.closest(excluded)) return; @@ -70,7 +137,7 @@ export function PageTranslator() { if (element.closest('option:not([value])')) return; const key = attribute ?? ''; const value = attribute ? (node as Element).getAttribute(attribute) : node.nodeValue; - if (!value || value.length > 10000 || !/\p{L}/u.test(value)) return; + if (!value || !/\p{L}/u.test(value)) return; let entries = targets.get(node); if (!entries) { entries = new Map(); targets.set(node, entries); } const old = entries.get(key); @@ -81,8 +148,14 @@ export function PageTranslator() { if (!active || !node.isConnected || entries?.get(key) !== target || read(target) !== value) return; target.translated = translated; write(target, translated); - }).catch(() => { /* Keep the original visible when translation fails. */ }); + }).catch((error) => { + if (!abort.signal.aborted && !failureReported) { + failureReported = true; + console.error('Page translation failed; original text was kept.', error); + } + }); }; + const scan = (root: Node) => { if (root instanceof Text) { consider(root); return; } if (!(root instanceof Element)) return; @@ -95,6 +168,7 @@ export function PageTranslator() { else if (node instanceof Element) for (const name of attributes) if (node.hasAttribute(name)) consider(node, name); } }; + const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'characterData') scan(mutation.target); @@ -102,8 +176,8 @@ export function PageTranslator() { else mutation.addedNodes.forEach(scan); } }); - observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] }); - scan(document.body); + observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] }); + scan(document.documentElement); return () => { active = false; abort.abort(); diff --git a/resources/js/translation/service.ts b/resources/js/translation/service.ts index cfede32a..d1b3d560 100644 --- a/resources/js/translation/service.ts +++ b/resources/js/translation/service.ts @@ -160,3 +160,45 @@ export async function translateText( targetLanguage: payload.target_lang ?? request.targetLanguage, }; } + +export async function translateTexts( + texts: string[], + sourceLanguage: string, + targetLanguage: string, + signal?: AbortSignal, +): Promise { + if (texts.length === 0) return []; + + const payload = await fetchJson<{ translated_text?: unknown }>( + `${translationApiBaseUrl}/translate`, + { + method: "POST", + signal, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: texts, + source_lang: sourceLanguage, + target_lang: targetLanguage, + }), + }, + 1, + 1, + ); + + if ( + !Array.isArray(payload.translated_text) || + payload.translated_text.length !== texts.length || + payload.translated_text.some( + (text) => typeof text !== "string" || text.trim() === "", + ) + ) { + throw new TranslationRequestError( + "Translation response does not contain all translated texts", + { status: 200 }, + ); + } + + return payload.translated_text; +} diff --git a/tests/Feature/TranslationApiTest.php b/tests/Feature/TranslationApiTest.php index 684a044d..34483ed8 100644 --- a/tests/Feature/TranslationApiTest.php +++ b/tests/Feature/TranslationApiTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use Illuminate\Support\Facades\Http; +use Illuminate\Http\Client\Request as ClientRequest; use Tests\TestCase; class TranslationApiTest extends TestCase @@ -24,4 +25,29 @@ class TranslationApiTest extends TestCase $this->postJson('/api/translate', $input)->assertOk()->assertJsonPath('translated_text', 'Hello'); $this->postJson('/api/translate', $input)->assertStatus(503); } + + public function test_it_translates_text_batches_for_full_page_translation(): void + { + config(['services.translation.url' => 'http://translator.test']); + Http::fake(['translator.test/translate' => Http::response(['translatedText' => ['Home', 'About us']])]); + + $this->postJson('/api/translate', [ + 'text' => ['Início', 'Sobre nós'], + 'source_lang' => 'auto', + 'target_lang' => 'en', + ])->assertOk() + ->assertJsonPath('translated_text.0', 'Home') + ->assertJsonPath('translated_text.1', 'About us'); + + Http::assertSent(fn (ClientRequest $request) => $request['q'] === ['Início', 'Sobre nós']); + } + + public function test_it_rejects_translation_batches_that_exceed_the_request_limit(): void + { + $this->postJson('/api/translate', [ + 'text' => array_fill(0, 9, 'Texto'), + 'source_lang' => 'auto', + 'target_lang' => 'en', + ])->assertUnprocessable(); + } }