363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
'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<string, string> = {},
|
|
) {
|
|
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<string, Record<string, string>>;
|
|
|
|
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<Set<string>>(
|
|
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<string, string> = {};
|
|
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 <div>No translations found</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 pb-24">
|
|
<div className="flex w-full items-center">
|
|
<div className="flex w-full items-center justify-between gap-2.5">
|
|
<div className="flex items-center gap-2.5">
|
|
<Input
|
|
type="search"
|
|
placeholder="Search translations..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="max-w-sm"
|
|
/>
|
|
|
|
<If condition={locales.length > 1}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="ml-auto">
|
|
Select Languages
|
|
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent align="end" className="w-[200px]">
|
|
{locales.map((locale) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={locale}
|
|
checked={selectedLocales.has(locale)}
|
|
onCheckedChange={() => toggleLocale(locale)}
|
|
disabled={
|
|
selectedLocales.size === 1 &&
|
|
selectedLocales.has(locale)
|
|
}
|
|
>
|
|
{locale}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</If>
|
|
|
|
<Select
|
|
value={selectedNamespace}
|
|
onValueChange={setSelectedNamespace}
|
|
>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Select namespace" />
|
|
</SelectTrigger>
|
|
|
|
<SelectContent>
|
|
{namespaces.map((namespace: string) => (
|
|
<SelectItem key={namespace} value={namespace}>
|
|
{namespace}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Button
|
|
onClick={handleTranslateWithAI}
|
|
disabled={isTranslating || !hasMissingTranslations}
|
|
>
|
|
{isTranslating ? (
|
|
<>
|
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
|
Translating...
|
|
</>
|
|
) : (
|
|
'Translate missing with AI'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Key</TableHead>
|
|
{visibleLocales.map((locale) => (
|
|
<TableHead key={locale}>{locale}</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
|
|
<TableBody>
|
|
{filteredKeys.map((key) => (
|
|
<TableRow key={key}>
|
|
<TableCell width={350} className="text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span>{key}</span>
|
|
</div>
|
|
</TableCell>
|
|
|
|
{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 (
|
|
<TableCell
|
|
key={locale}
|
|
className={cn({
|
|
'bg-destructive/10': isMissing,
|
|
'bg-warning/10': !isMissing && isDifferent,
|
|
})}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<Input
|
|
defaultValue={value || ''}
|
|
onChange={(e) => {
|
|
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' : ''}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|