'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ChevronDownIcon, Loader2Icon } from 'lucide-react'; import { Subject, debounceTime } from 'rxjs'; import { Button } from '@kit/ui/button'; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from '@kit/ui/dropdown-menu'; import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@kit/ui/select'; import { toast } from '@kit/ui/sonner'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@kit/ui/table'; import { cn } from '@kit/ui/utils'; import { translateWithAIAction, updateTranslationAction, } from '../lib/server-actions'; import type { TranslationData, Translations } from '../lib/translations-loader'; function flattenTranslations( obj: TranslationData, prefix = '', result: Record = {}, ) { for (const [key, value] of Object.entries(obj)) { const newKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'string') { result[newKey] = value; } else { flattenTranslations(value, newKey, result); } } return result; } type FlattenedTranslations = Record>; export function TranslationsComparison({ translations, }: { translations: Translations; }) { const [search, setSearch] = useState(''); const [isTranslating, setIsTranslating] = useState(false); // Create RxJS Subject for handling translation updates const subject$ = useMemo( () => new Subject<{ locale: string; namespace: string; key: string; value: string; }>(), [], ); const locales = Object.keys(translations); const baseLocale = locales[0]!; const namespaces = Object.keys(translations[baseLocale] || {}); const [selectedLocales, setSelectedLocales] = useState>( new Set(locales), ); // Flatten translations for the selected namespace const flattenedTranslations: FlattenedTranslations = {}; const [selectedNamespace, setSelectedNamespace] = useState( namespaces[0] as string, ); for (const locale of locales) { const namespaceData = translations[locale]?.[selectedNamespace]; if (namespaceData) { flattenedTranslations[locale] = flattenTranslations(namespaceData); } else { flattenedTranslations[locale] = {}; } } // Get all unique keys across all translations const allKeys = Array.from( new Set( Object.values(flattenedTranslations).flatMap((data) => Object.keys(data)), ), ).sort(); const filteredKeys = allKeys.filter((key) => key.toLowerCase().includes(search.toLowerCase()), ); const visibleLocales = locales.filter((locale) => selectedLocales.has(locale), ); const toggleLocale = (locale: string) => { const newSelectedLocales = new Set(selectedLocales); if (newSelectedLocales.has(locale)) { if (newSelectedLocales.size > 1) { newSelectedLocales.delete(locale); } } else { newSelectedLocales.add(locale); } setSelectedLocales(newSelectedLocales); }; const handleTranslateWithAI = useCallback(async () => { try { setIsTranslating(true); // Get missing translations for the selected namespace const missingTranslations: Record = {}; const baseTranslations = flattenedTranslations[baseLocale] ?? {}; for (const locale of visibleLocales) { if (locale === baseLocale) continue; const localeTranslations = flattenedTranslations[locale] ?? {}; for (const [key, value] of Object.entries(baseTranslations)) { if (!localeTranslations[key]) { missingTranslations[key] = value; } } if (Object.keys(missingTranslations).length > 0) { await translateWithAIAction({ sourceLocale: baseLocale, targetLocale: locale, namespace: selectedNamespace, translations: missingTranslations, }); toast.success(`Translated missing strings to ${locale}`); } } } catch (error) { toast.error('Failed to translate: ' + (error as Error).message); } finally { setIsTranslating(false); } }, [flattenedTranslations, baseLocale, visibleLocales, selectedNamespace]); // Calculate if there are any missing translations const hasMissingTranslations = useMemo(() => { if (!flattenedTranslations || !baseLocale || !visibleLocales) return false; const baseTranslations = flattenedTranslations[baseLocale] ?? {}; return visibleLocales.some((locale) => { if (locale === baseLocale) return false; const localeTranslations = flattenedTranslations[locale] ?? {}; return Object.keys(baseTranslations).some( (key) => !localeTranslations[key], ); }); }, [flattenedTranslations, baseLocale, visibleLocales]); // Set up subscription to handle debounced updates useEffect(() => { const subscription = subject$.pipe(debounceTime(500)).subscribe((props) => { updateTranslationAction(props) .then(() => { toast.success(`Updated translation for ${props.key}`); }) .catch((err) => { toast.error(`Failed to update translation: ${err.message}`); }); }); return () => subscription.unsubscribe(); }, [subject$]); if (locales.length === 0) { return
No translations found
; } return (
setSearch(e.target.value)} className="max-w-sm" /> 1}> {locales.map((locale) => ( toggleLocale(locale)} disabled={ selectedLocales.size === 1 && selectedLocales.has(locale) } > {locale} ))}
Key {visibleLocales.map((locale) => ( {locale} ))} {filteredKeys.map((key) => (
{key}
{visibleLocales.map((locale) => { const translations = flattenedTranslations[locale] ?? {}; const baseTranslations = flattenedTranslations[baseLocale] ?? {}; const value = translations[key]; const baseValue = baseTranslations[key]; const isMissing = !value; const isDifferent = value !== baseValue; return (
{ const value = e.target.value.trim(); if (value === '') { toast.error('Translation cannot be empty'); return; } if (value === baseValue) { toast.info('Translation is the same as base'); return; } subject$.next({ locale, namespace: selectedNamespace, key, value, }); }} className="w-full font-mono text-sm" placeholder={isMissing ? 'Missing translation' : ''} />
); })}
))}
); }