"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { NexusNav } from "@/app/components/NexusNav"; import { StatusBar } from "@/app/components/StatusBar"; type TranslateLanguage = | "english" | "spanish" | "french" | "portuguese" | "german" | "swahili" | "twi" | "kikuyu" | "italian" | "dutch" | "arabic" | "hindi" | "russian" | "ukrainian" | "turkish" | "chinese" | "japanese" | "korean" | "amharic" | "yoruba" | "hausa"; type SpeechLanguage = "auto" | "english" | "spanish" | "french" | "portuguese" | "german" | "swahili" | "twi" | "kikuyu"; type WorkMode = "text" | "document" | "live"; type ControlMode = "tap" | "hold"; type SpeakerId = "A" | "B"; type LiveTurn = { id: string; speaker: SpeakerId; source: string; translation: string; confidence: number; latencyMs: number; createdAt: string; }; type TranslateSession = { id: string; name: string; createdAt: string; updatedAt: string; mode: WorkMode; targetLanguage: TranslateLanguage; sourceText: string; translatedText: string; liveTurns: LiveTurn[]; }; const LANGUAGE_OPTIONS: Array<{ value: TranslateLanguage; label: string }> = [ { value: "english", label: "English" }, { value: "spanish", label: "Spanish" }, { value: "french", label: "French" }, { value: "portuguese", label: "Portuguese" }, { value: "german", label: "German" }, { value: "swahili", label: "Swahili" }, { value: "twi", label: "Twi" }, { value: "kikuyu", label: "Kikuyu" }, { value: "italian", label: "Italian" }, { value: "dutch", label: "Dutch" }, { value: "arabic", label: "Arabic" }, { value: "hindi", label: "Hindi" }, { value: "russian", label: "Russian" }, { value: "ukrainian", label: "Ukrainian" }, { value: "turkish", label: "Turkish" }, { value: "chinese", label: "Chinese" }, { value: "japanese", label: "Japanese" }, { value: "korean", label: "Korean" }, { value: "amharic", label: "Amharic" }, { value: "yoruba", label: "Yoruba" }, { value: "hausa", label: "Hausa" }, ]; const SESSION_KEY = "nexus_translate_sessions_v1"; const DOCUMENT_CHUNK_SIZE = 4200; const DOCUMENT_CONCURRENCY = 3; function splitChunks(text: string, maxChars = 1800): string[] { const normalized = text.replace(/\r\n/g, "\n").trim(); if (!normalized) return []; const chunks: string[] = []; let remaining = normalized; while (remaining.length > maxChars) { let cut = remaining.lastIndexOf("\n", maxChars); if (cut < Math.floor(maxChars * 0.55)) cut = remaining.lastIndexOf(" ", maxChars); if (cut < Math.floor(maxChars * 0.4)) cut = maxChars; chunks.push(remaining.slice(0, cut).trim()); remaining = remaining.slice(cut).trim(); } if (remaining) chunks.push(remaining); return chunks.filter(Boolean); } function chooseRecorderMimeType(): string { const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]; for (const candidate of candidates) { if (MediaRecorder.isTypeSupported(candidate)) return candidate; } return ""; } function slugify(input: string): string { const out = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return out || "nexus-translate"; } function parseGlossary(input: string): Array<{ source: string; target: string }> { return input .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => { const [source, ...rest] = line.split("=>"); return { source: source?.trim() ?? "", target: rest.join("=>").trim(), }; }) .filter((item) => item.source && item.target); } function parseProtectedTerms(input: string): string[] { return input .split(/,|\n/) .map((item) => item.trim()) .filter(Boolean); } function cleanupLiveText(text: string): string { return text .replace(/\b(uh|um|er|ah)\b/gi, "") .replace(/\s{2,}/g, " ") .trim(); } function formatSrtTime(totalMs: number): string { const ms = Math.max(0, Math.floor(totalMs)); const hh = Math.floor(ms / 3600000); const mm = Math.floor((ms % 3600000) / 60000); const ss = Math.floor((ms % 60000) / 1000); const mmm = ms % 1000; const pad = (value: number, size: number) => value.toString().padStart(size, "0"); return `${pad(hh, 2)}:${pad(mm, 2)}:${pad(ss, 2)},${pad(mmm, 3)}`; } export default function TranslatePage() { const router = useRouter(); const fileRef = useRef(null); const recorderRef = useRef(null); const streamRef = useRef(null); const socketRef = useRef(null); const liveQueueRef = useRef>(Promise.resolve()); const [mode, setMode] = useState("text"); const [targetLanguage, setTargetLanguage] = useState("english"); const [speechLanguage, setSpeechLanguage] = useState("auto"); const [controlMode, setControlMode] = useState("tap"); const [activeSpeaker, setActiveSpeaker] = useState("A"); const [cleanupEnabled, setCleanupEnabled] = useState(true); const [sourceText, setSourceText] = useState(""); const [translatedText, setTranslatedText] = useState(""); const [docExtractedText, setDocExtractedText] = useState(""); const [sourceName, setSourceName] = useState(""); const [documentWasTruncated, setDocumentWasTruncated] = useState(false); const [documentOriginalLength, setDocumentOriginalLength] = useState(null); const [liveTurns, setLiveTurns] = useState([]); const [interimSpeech, setInterimSpeech] = useState(""); const [glossaryText, setGlossaryText] = useState(""); const [protectedTermsText, setProtectedTermsText] = useState(""); const [progress, setProgress] = useState(0); const [isTranslating, setIsTranslating] = useState(false); const [isListening, setIsListening] = useState(false); const [status, setStatus] = useState("Ready"); const [sessions, setSessions] = useState([]); const [sessionSearch, setSessionSearch] = useState(""); const [mobileTerminologyOpen, setMobileTerminologyOpen] = useState(false); const [textViewTab, setTextViewTab] = useState<"source" | "translated">("source"); const [documentViewTab, setDocumentViewTab] = useState<"source" | "translated">("source"); const targetLabel = useMemo( () => LANGUAGE_OPTIONS.find((item) => item.value === targetLanguage)?.label ?? "English", [targetLanguage], ); const glossary = useMemo(() => parseGlossary(glossaryText), [glossaryText]); const protectedTerms = useMemo(() => parseProtectedTerms(protectedTermsText), [protectedTermsText]); const liveSourceText = useMemo(() => liveTurns.map((turn) => `Speaker ${turn.speaker}: ${turn.source}`).join("\n"), [liveTurns]); const liveTranslatedText = useMemo(() => liveTurns.map((turn) => `Speaker ${turn.speaker}: ${turn.translation}`).join("\n"), [liveTurns]); const filteredSessions = useMemo(() => { const q = sessionSearch.trim().toLowerCase(); if (!q) return sessions; return sessions.filter((session) => session.name.toLowerCase().includes(q) || session.sourceText.toLowerCase().includes(q) || session.translatedText.toLowerCase().includes(q), ); }, [sessionSearch, sessions]); useEffect(() => { try { const raw = localStorage.getItem(SESSION_KEY); if (!raw) return; const parsed = JSON.parse(raw) as TranslateSession[]; if (Array.isArray(parsed)) setSessions(parsed); } catch { // ignore } }, []); const persistSessions = useCallback((next: TranslateSession[]) => { setSessions(next); localStorage.setItem(SESSION_KEY, JSON.stringify(next)); }, []); const handleNavSelect = useCallback((id: string) => { if (id === "ebook") { router.push("/ebook?tab=pipeline"); return; } if (id === "translate") { router.push("/translate"); return; } router.push("/"); }, [router]); const requestTranslate = useCallback(async (text: string, lang: TranslateLanguage): Promise => { const res = await fetch("/api/sermon-assistant/translate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, lang, glossary, protectedTerms }), }); const json = await res.json() as { translation?: string; error?: string }; if (!res.ok || !json.translation) { throw new Error(json.error ?? "Translation failed"); } return json.translation.trim(); }, [glossary, protectedTerms]); const runTranslation = useCallback(async (text: string, lang: TranslateLanguage) => { const chunks = splitChunks(text, DOCUMENT_CHUNK_SIZE); if (chunks.length === 0) { setStatus("No text to translate"); return; } if (chunks.length > 80) { setStatus("Content too large. Reduce input size."); return; } setIsTranslating(true); setProgress(0); setStatus("Translating..."); try { const out = new Array(chunks.length); let completed = 0; for (let start = 0; start < chunks.length; start += DOCUMENT_CONCURRENCY) { const batch = chunks.slice(start, start + DOCUMENT_CONCURRENCY); const results = await Promise.all( batch.map(async (chunk, offset) => { const translated = await requestTranslate(chunk, lang); completed += 1; setProgress(Math.round((completed / chunks.length) * 100)); return { index: start + offset, translated }; }), ); for (const result of results) { out[result.index] = result.translated; } } setTranslatedText(out.join("\n\n").trim()); setStatus(`Translated to ${targetLabel}.`); } catch { setStatus("Translation failed."); } finally { setIsTranslating(false); } }, [requestTranslate, targetLabel]); const stopLiveSpeech = useCallback(() => { if (recorderRef.current && recorderRef.current.state !== "inactive") { recorderRef.current.stop(); } if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current = null; } if (socketRef.current) { try { socketRef.current.close(); } catch { // ignore } socketRef.current = null; } setInterimSpeech(""); setIsListening(false); setStatus("Ready"); }, []); const processLiveFinal = useCallback(async (transcriptRaw: string, confidence: number) => { const source = cleanupEnabled ? cleanupLiveText(transcriptRaw) : transcriptRaw.trim(); if (!source) return; const started = performance.now(); try { const translation = await requestTranslate(source, targetLanguage); const latencyMs = Math.round(performance.now() - started); setLiveTurns((prev) => [ ...prev, { id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, speaker: activeSpeaker, source, translation, confidence, latencyMs, createdAt: new Date().toISOString(), }, ]); setStatus(`Live translated (${latencyMs}ms)`); } catch { setStatus("Live translation chunk failed."); } }, [activeSpeaker, cleanupEnabled, requestTranslate, targetLanguage]); const startLiveSpeech = useCallback(async () => { const tokenRes = await fetch("/api/transcribe-token", { method: "GET" }); if (!tokenRes.ok) { setStatus("Deepgram key unavailable."); return; } const tokenData = await tokenRes.json() as { apiKey?: string }; const apiKey = tokenData.apiKey?.trim(); if (!apiKey) { setStatus("Deepgram key unavailable."); return; } const manualLanguageCode: Record, string> = { english: "en", spanish: "es", french: "fr", portuguese: "pt", german: "de", swahili: "sw", }; const params = new URLSearchParams({ model: "nova-2", endpointing: "500", punctuate: "true", interim_results: "true", smart_format: "true", }); const isLimitedLanguage = speechLanguage === "twi" || speechLanguage === "kikuyu"; if (speechLanguage === "auto" || isLimitedLanguage) { params.set("detect_language", "true"); } else { params.set("language", manualLanguageCode[speechLanguage]); } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; setStatus("Connecting interpreter..."); const ws = new WebSocket(`wss://api.deepgram.com/v1/listen?${params.toString()}`, ["token", apiKey]); socketRef.current = ws; ws.onopen = () => { const mimeType = chooseRecorderMimeType(); const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); recorderRef.current = recorder; recorder.ondataavailable = (event) => { if (event.data.size > 0 && ws.readyState === WebSocket.OPEN) { ws.send(event.data); } }; recorder.start(250); setIsListening(true); setStatus("Listening live..."); }; ws.onmessage = (event) => { const payload = JSON.parse(event.data) as { type?: string; is_final?: boolean; channel?: { alternatives?: Array<{ transcript?: string; confidence?: number }>; }; }; if (payload.type === "Error") { setStatus("Live speech connection failed."); stopLiveSpeech(); return; } const alt = payload.channel?.alternatives?.[0]; const transcript = alt?.transcript?.trim() ?? ""; if (!transcript) return; if (payload.is_final) { setInterimSpeech(""); const conf = Number.isFinite(alt?.confidence) ? Number(alt?.confidence) : 0; liveQueueRef.current = liveQueueRef.current.then(() => processLiveFinal(transcript, conf)); } else { setInterimSpeech(transcript); } }; ws.onerror = () => { setStatus("Live speech socket error."); stopLiveSpeech(); }; ws.onclose = () => { stopLiveSpeech(); }; }, [processLiveFinal, speechLanguage, stopLiveSpeech]); const toggleLiveSpeech = useCallback(async () => { if (isListening) { stopLiveSpeech(); return; } try { await startLiveSpeech(); } catch { setStatus("Microphone access denied."); stopLiveSpeech(); } }, [isListening, startLiveSpeech, stopLiveSpeech]); useEffect(() => () => stopLiveSpeech(), [stopLiveSpeech]); const handleTranslateText = useCallback(async () => { const text = sourceText.trim(); if (!text) { setStatus("Paste text or upload a document first."); return; } await runTranslation(text, targetLanguage); setTextViewTab("translated"); }, [runTranslation, sourceText, targetLanguage]); const handleUploadDocument = useCallback(async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; setSourceName(file.name); try { const formData = new FormData(); formData.append("file", file); const res = await fetch("/api/sermon-assistant/document-extract", { method: "POST", body: formData, }); const json = await res.json() as { text?: string; error?: string; truncated?: boolean; originalLength?: number }; if (!res.ok || !json.text) throw new Error(json.error ?? "Could not extract document text"); const extracted = json.text.trim(); setDocumentWasTruncated(Boolean(json.truncated)); setDocumentOriginalLength(typeof json.originalLength === "number" ? json.originalLength : extracted.length); setDocExtractedText(extracted); setSourceText(extracted); await runTranslation(extracted, targetLanguage); setDocumentViewTab("translated"); } catch (error) { setStatus(error instanceof Error ? error.message : "Document upload/translation failed."); } finally { event.target.value = ""; } }, [runTranslation, targetLanguage]); const downloadText = useCallback((name: string, content: string, extension: string) => { const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${slugify(name)}.${extension}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, []); const exportTxt = useCallback(() => { const content = mode === "live" ? liveTranslatedText : translatedText; if (!content.trim()) return; downloadText(`${sourceName || "nexus-translate"}-${targetLanguage}`, content, "txt"); }, [downloadText, liveTranslatedText, mode, sourceName, targetLanguage, translatedText]); const exportJson = useCallback(() => { const payload = { mode, targetLanguage, sourceText, translatedText, liveTurns, glossary, protectedTerms, exportedAt: new Date().toISOString(), }; downloadText(`nexus-translate-${Date.now()}`, JSON.stringify(payload, null, 2), "json"); }, [downloadText, glossary, liveTurns, mode, protectedTerms, sourceText, targetLanguage, translatedText]); const exportSrt = useCallback(() => { if (liveTurns.length === 0) return; const lines: string[] = []; for (let i = 0; i < liveTurns.length; i += 1) { const start = i * 3200; const end = start + 3000; lines.push(String(i + 1)); lines.push(`${formatSrtTime(start)} --> ${formatSrtTime(end)}`); lines.push(`Speaker ${liveTurns[i].speaker}: ${liveTurns[i].translation}`); lines.push(""); } downloadText(`nexus-live-${Date.now()}`, lines.join("\n"), "srt"); }, [downloadText, liveTurns]); const exportDocx = useCallback(async () => { const { AlignmentType, Document, HeadingLevel, Packer, Paragraph, TextRun } = await import("docx"); const text = mode === "live" ? liveTranslatedText : translatedText; if (!text.trim()) return; const title = `Nexus Translate - ${targetLabel}`; const doc = new Document({ creator: "Nexus Director", title, sections: [{ children: [ new Paragraph({ text: title, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }), new Paragraph({ children: [new TextRun({ text: `Generated ${new Date().toLocaleString()}`, italics: true })], alignment: AlignmentType.CENTER, }), new Paragraph({ text: "" }), ...text.split(/\r?\n/).map((line) => new Paragraph({ text: line })), ], }], }); const blob = await Packer.toBlob(doc); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${slugify(sourceName || "nexus-translate")}-${targetLanguage}.docx`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, [liveTranslatedText, mode, sourceName, targetLabel, targetLanguage, translatedText]); const saveSession = useCallback(() => { const nameBase = sourceName || sourceText.split(/\r?\n/)[0] || `Session ${new Date().toLocaleTimeString()}`; const now = new Date().toISOString(); const session: TranslateSession = { id: `session-${Date.now()}`, name: nameBase.slice(0, 64), createdAt: now, updatedAt: now, mode, targetLanguage, sourceText, translatedText, liveTurns, }; persistSessions([session, ...sessions].slice(0, 100)); setStatus("Session saved."); }, [liveTurns, mode, persistSessions, sessions, sourceName, sourceText, targetLanguage, translatedText]); const loadSession = useCallback((session: TranslateSession) => { setMode(session.mode); setTargetLanguage(session.targetLanguage); setSourceText(session.sourceText); setTranslatedText(session.translatedText); setLiveTurns(session.liveTurns); setStatus(`Loaded session: ${session.name}`); }, []); const confidenceBadge = (confidence: number): string => { if (confidence >= 0.86) return "High"; if (confidence >= 0.68) return "Medium"; return "Low"; }; return (
{/* Row 1: title + status */}

Nexus Translate

Editorial Studio

{status}

{/* Row 2: mode switcher + language */}
{(["text", "document", "live"] as WorkMode[]).map((item) => ( ))}
{/* Row 3: action strip — horizontally scrollable on mobile */}
{mode === "document" && ( )}
{mode === "text" && (

{textViewTab === "source" ? "Source Text" : `Translation (${targetLabel})`}

{textViewTab === "source" ? (