- Replace dynamic t(lang.labelKey) with static t(lang.label) using helper functions - Add getLanguageLabel/getLangLabel/getLocaleLabel helper functions for switch-based label lookup - Simplify translation check scripts to only detect literal string calls - Fix namespace lookup for dotted namespaces like 'memorize.review'
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { LightButton, PrimaryButton, IconClick, CircleButton } from "@/design-system/base/button";
|
|
import { Input } from "@/design-system/base/input";
|
|
import { Textarea } from "@/design-system/base/textarea";
|
|
import { Select } from "@/design-system/base/select";
|
|
import { IMAGES } from "@/config/images";
|
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
|
import { useTranslations } from "next-intl";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { actionTranslateText } from "@/modules/translator/translator-action";
|
|
import { actionCreateCard } from "@/modules/card/card-action";
|
|
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
|
import type { CardType } from "@/modules/card/card-action-dto";
|
|
import { toast } from "sonner";
|
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
|
import { TSharedTranslationResult } from "@/shared/translator-type";
|
|
import { Plus } from "lucide-react";
|
|
import { authClient } from "@/lib/auth-client";
|
|
|
|
const SOURCE_LANGUAGES = [
|
|
{ value: "Auto", label: "auto" },
|
|
{ value: "Chinese", label: "chinese" },
|
|
{ value: "English", label: "english" },
|
|
{ value: "Japanese", label: "japanese" },
|
|
{ value: "Korean", label: "korean" },
|
|
{ value: "French", label: "french" },
|
|
{ value: "German", label: "german" },
|
|
{ value: "Italian", label: "italian" },
|
|
{ value: "Spanish", label: "spanish" },
|
|
{ value: "Portuguese", label: "portuguese" },
|
|
{ value: "Russian", label: "russian" },
|
|
] as const;
|
|
|
|
const TARGET_LANGUAGES = [
|
|
{ value: "Chinese", label: "chinese" },
|
|
{ value: "English", label: "english" },
|
|
{ value: "Japanese", label: "japanese" },
|
|
{ value: "Korean", label: "korean" },
|
|
{ value: "French", label: "french" },
|
|
{ value: "German", label: "german" },
|
|
{ value: "Italian", label: "italian" },
|
|
{ value: "Spanish", label: "spanish" },
|
|
{ value: "Portuguese", label: "portuguese" },
|
|
{ value: "Russian", label: "russian" },
|
|
] as const;
|
|
|
|
type LangLabel = typeof SOURCE_LANGUAGES[number]["label"];
|
|
|
|
function getLangLabel(t: (key: string) => string, label: LangLabel): string {
|
|
switch (label) {
|
|
case "auto": return t("auto");
|
|
case "chinese": return t("chinese");
|
|
case "english": return t("english");
|
|
case "japanese": return t("japanese");
|
|
case "korean": return t("korean");
|
|
case "french": return t("french");
|
|
case "german": return t("german");
|
|
case "italian": return t("italian");
|
|
case "spanish": return t("spanish");
|
|
case "portuguese": return t("portuguese");
|
|
case "russian": return t("russian");
|
|
}
|
|
}
|
|
|
|
// Estimated button width in pixels (including gap)
|
|
const BUTTON_WIDTH = 80;
|
|
const LABEL_WIDTH = 100;
|
|
const INPUT_WIDTH = 140;
|
|
const IPA_BUTTON_WIDTH = 100;
|
|
|
|
export default function TranslatorPage() {
|
|
const t = useTranslations("translator");
|
|
|
|
const taref = useRef<HTMLTextAreaElement>(null);
|
|
const sourceContainerRef = useRef<HTMLDivElement>(null);
|
|
const targetContainerRef = useRef<HTMLDivElement>(null);
|
|
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
|
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
|
const [customSourceLanguage, setCustomSourceLanguage] = useState<string>("");
|
|
const [customTargetLanguage, setCustomTargetLanguage] = useState<string>("");
|
|
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
|
const [needIpa, setNeedIpa] = useState(true);
|
|
const [processing, setProcessing] = useState(false);
|
|
const [lastTranslation, setLastTranslation] = useState<{
|
|
sourceText: string;
|
|
sourceLanguage: string;
|
|
targetLanguage: string;
|
|
} | null>(null);
|
|
const [sourceButtonCount, setSourceButtonCount] = useState(2);
|
|
const [targetButtonCount, setTargetButtonCount] = useState(2);
|
|
const { load, play } = useAudioPlayer();
|
|
|
|
const { data: session } = authClient.useSession();
|
|
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (session?.user?.id) {
|
|
actionGetDecksByUserId(session.user.id).then((result) => {
|
|
if (result.success && result.data) {
|
|
setDecks(result.data);
|
|
}
|
|
});
|
|
}
|
|
}, [session?.user?.id]);
|
|
|
|
// Calculate how many buttons to show based on container width
|
|
const calculateButtonCount = useCallback((containerWidth: number, hasIpa: boolean) => {
|
|
// Reserve space for label, input, and IPA button (for source)
|
|
const reservedWidth = LABEL_WIDTH + INPUT_WIDTH + (hasIpa ? IPA_BUTTON_WIDTH : 0);
|
|
const availableWidth = containerWidth - reservedWidth;
|
|
return Math.max(0, Math.floor(availableWidth / BUTTON_WIDTH));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const updateButtonCounts = () => {
|
|
if (sourceContainerRef.current) {
|
|
const width = sourceContainerRef.current.offsetWidth;
|
|
setSourceButtonCount(calculateButtonCount(width, true));
|
|
}
|
|
if (targetContainerRef.current) {
|
|
const width = targetContainerRef.current.offsetWidth;
|
|
setTargetButtonCount(calculateButtonCount(width, false));
|
|
}
|
|
};
|
|
|
|
updateButtonCounts();
|
|
window.addEventListener("resize", updateButtonCounts);
|
|
return () => window.removeEventListener("resize", updateButtonCounts);
|
|
}, [calculateButtonCount]);
|
|
|
|
const tts = useCallback(async (text: string, locale: string) => {
|
|
try {
|
|
// Map language name to TTS format
|
|
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
|
|
|
// Check if language is in TTS supported list
|
|
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
|
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
|
"Spanish", "Japanese", "Korean", "French", "Russian"
|
|
];
|
|
|
|
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
|
|
theLanguage = "Auto";
|
|
}
|
|
|
|
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
|
await load(url);
|
|
await play();
|
|
} catch (error) {
|
|
toast.error("Failed to generate audio");
|
|
}
|
|
}, [load, play]);
|
|
|
|
const translate = async () => {
|
|
if (!taref.current || processing) return;
|
|
|
|
setProcessing(true);
|
|
|
|
const sourceText = taref.current.value;
|
|
const effectiveSourceLanguage = customSourceLanguage.trim() || sourceLanguage;
|
|
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
|
|
|
|
// 判断是否需要强制重新翻译
|
|
const forceRetranslate =
|
|
lastTranslation?.sourceText === sourceText &&
|
|
lastTranslation?.sourceLanguage === effectiveSourceLanguage &&
|
|
lastTranslation?.targetLanguage === effectiveTargetLanguage;
|
|
|
|
try {
|
|
const result = await actionTranslateText({
|
|
sourceText,
|
|
targetLanguage: effectiveTargetLanguage,
|
|
forceRetranslate,
|
|
needIpa,
|
|
sourceLanguage: effectiveSourceLanguage === "Auto" ? undefined : effectiveSourceLanguage,
|
|
});
|
|
|
|
if (result.success && result.data) {
|
|
setTranslationResult(result.data);
|
|
setLastTranslation({
|
|
sourceText,
|
|
sourceLanguage: effectiveSourceLanguage,
|
|
targetLanguage: effectiveTargetLanguage,
|
|
});
|
|
} else {
|
|
toast.error(result.message || "翻译失败,请重试");
|
|
}
|
|
} catch (error) {
|
|
toast.error("翻译失败,请重试");
|
|
console.error("翻译错误:", error);
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const visibleSourceButtons = SOURCE_LANGUAGES.slice(0, sourceButtonCount);
|
|
const visibleTargetButtons = TARGET_LANGUAGES.slice(0, targetButtonCount);
|
|
|
|
const handleSaveCard = async () => {
|
|
if (!session) {
|
|
toast.error(t("pleaseLogin"));
|
|
return;
|
|
}
|
|
if (decks.length === 0) {
|
|
toast.error(t("pleaseCreateDeck"));
|
|
return;
|
|
}
|
|
if (!lastTranslation?.sourceText || !translationResult?.translatedText) {
|
|
toast.error(t("noTranslationToSave"));
|
|
return;
|
|
}
|
|
|
|
const deckSelect = document.getElementById("deck-select-translator") as HTMLSelectElement;
|
|
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
|
|
|
if (!deckId) {
|
|
toast.error(t("noDeckSelected"));
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
const sourceText = lastTranslation.sourceText;
|
|
const hasSpaces = sourceText.includes(" ");
|
|
let cardType: CardType = "WORD";
|
|
if (!translationResult.sourceIpa) {
|
|
cardType = "SENTENCE";
|
|
} else if (hasSpaces) {
|
|
cardType = "PHRASE";
|
|
}
|
|
|
|
await actionCreateCard({
|
|
deckId,
|
|
word: sourceText,
|
|
ipa: translationResult.sourceIpa || null,
|
|
queryLang: lastTranslation.sourceLanguage,
|
|
cardType,
|
|
meanings: [{
|
|
partOfSpeech: null,
|
|
definition: translationResult.translatedText,
|
|
example: null,
|
|
}],
|
|
});
|
|
|
|
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
|
toast.success(t("savedToDeck", { deckName }));
|
|
setShowSaveModal(false);
|
|
} catch (error) {
|
|
toast.error(t("saveFailed"));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-[calc(100vh-64px)] bg-white">
|
|
{/* TCard Component */}
|
|
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
|
{/* Card Component - Left Side */}
|
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
|
{/* ICard1 Component */}
|
|
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
|
|
<Textarea
|
|
className="resize-none h-8/12 w-full"
|
|
ref={taref}
|
|
onKeyDown={(e) => {
|
|
if (e.ctrlKey && e.key === "Enter") translate();
|
|
}}
|
|
/>
|
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
|
{translationResult?.sourceIpa || ""}
|
|
</div>
|
|
<div className="h-2/12 w-full flex justify-end items-center">
|
|
<IconClick
|
|
src={IMAGES.copy_all}
|
|
alt="copy"
|
|
onClick={async () => {
|
|
await navigator.clipboard.writeText(
|
|
taref.current?.value || "",
|
|
);
|
|
}}
|
|
></IconClick>
|
|
<IconClick
|
|
src={IMAGES.play_arrow}
|
|
alt="play"
|
|
onClick={() => {
|
|
const text = taref.current?.value;
|
|
if (!text) return;
|
|
tts(text, translationResult?.sourceLanguage || "");
|
|
}}
|
|
></IconClick>
|
|
</div>
|
|
</div>
|
|
<div ref={sourceContainerRef} className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
|
<span className="shrink-0">{t("sourceLanguage")}</span>
|
|
{visibleSourceButtons.map((lang) => (
|
|
<LightButton
|
|
key={lang.value}
|
|
selected={!customSourceLanguage && sourceLanguage === lang.value}
|
|
onClick={() => {
|
|
setSourceLanguage(lang.value);
|
|
setCustomSourceLanguage("");
|
|
}}
|
|
className="shrink-0"
|
|
>
|
|
{getLangLabel(t, lang.label)}
|
|
</LightButton>
|
|
))}
|
|
<Input
|
|
variant="bordered"
|
|
size="sm"
|
|
value={customSourceLanguage}
|
|
onChange={(e) => setCustomSourceLanguage(e.target.value)}
|
|
placeholder={t("customLanguage")}
|
|
className="w-auto min-w-[120px] shrink-0"
|
|
/>
|
|
<div className="flex-1"></div>
|
|
<LightButton
|
|
selected={needIpa}
|
|
onClick={() => setNeedIpa((prev) => !prev)}
|
|
className="shrink-0"
|
|
>
|
|
{t("generateIPA")}
|
|
</LightButton>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card Component - Right Side */}
|
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
|
{/* ICard2 Component */}
|
|
<div className="bg-gray-100 rounded-lg w-full h-64 p-2">
|
|
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
|
|
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
|
{translationResult?.targetIpa || ""}
|
|
</div>
|
|
<div className="h-1/6 w-full flex justify-end items-center">
|
|
<IconClick
|
|
src={IMAGES.copy_all}
|
|
alt="copy"
|
|
onClick={async () => {
|
|
await navigator.clipboard.writeText(translationResult?.translatedText || "");
|
|
}}
|
|
></IconClick>
|
|
<IconClick
|
|
src={IMAGES.play_arrow}
|
|
alt="play"
|
|
onClick={() => {
|
|
if (!translationResult) return;
|
|
tts(
|
|
translationResult.translatedText,
|
|
translationResult.targetLanguage,
|
|
);
|
|
}}
|
|
></IconClick>
|
|
</div>
|
|
</div>
|
|
<div ref={targetContainerRef} className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
|
<span className="shrink-0">{t("translateInto")}</span>
|
|
{visibleTargetButtons.map((lang) => (
|
|
<LightButton
|
|
key={lang.value}
|
|
selected={!customTargetLanguage && targetLanguage === lang.value}
|
|
onClick={() => {
|
|
setTargetLanguage(lang.value);
|
|
setCustomTargetLanguage("");
|
|
}}
|
|
className="shrink-0"
|
|
>
|
|
{getLangLabel(t, lang.label)}
|
|
</LightButton>
|
|
))}
|
|
<Input
|
|
variant="bordered"
|
|
size="sm"
|
|
value={customTargetLanguage}
|
|
onChange={(e) => setCustomTargetLanguage(e.target.value)}
|
|
placeholder={t("customLanguage")}
|
|
className="w-auto min-w-[120px] shrink-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* TranslateButton Component */}
|
|
<div className="w-screen flex justify-center items-center gap-4">
|
|
<PrimaryButton
|
|
onClick={translate}
|
|
disabled={processing}
|
|
size="lg"
|
|
className="text-xl"
|
|
>
|
|
{t("translate")}
|
|
</PrimaryButton>
|
|
{translationResult && session && decks.length > 0 && (
|
|
<CircleButton
|
|
onClick={() => setShowSaveModal(true)}
|
|
title={t("saveAsCard")}
|
|
>
|
|
<Plus size={20} />
|
|
</CircleButton>
|
|
)}
|
|
</div>
|
|
|
|
{showSaveModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
|
<h2 className="text-xl font-semibold mb-4">{t("saveAsCard")}</h2>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{t("selectDeck")}
|
|
</label>
|
|
<Select id="deck-select-translator" className="w-full">
|
|
{decks.map((deck) => (
|
|
<option key={deck.id} value={deck.id}>
|
|
{deck.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
|
|
<div className="font-medium mb-1">{t("front")}:</div>
|
|
<div className="text-gray-700 mb-2">{lastTranslation?.sourceText}</div>
|
|
<div className="font-medium mb-1">{t("back")}:</div>
|
|
<div className="text-gray-700">{translationResult?.translatedText}</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<LightButton onClick={() => setShowSaveModal(false)}>
|
|
{t("cancel")}
|
|
</LightButton>
|
|
<PrimaryButton onClick={handleSaveCard} loading={isSaving}>
|
|
{t("save")}
|
|
</PrimaryButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|