Files
learn-languages/src/app/decks/[deck_id]/AddCardModal.tsx
goddonebianu 1ef337801d refactor: unify i18n function calls and simplify scripts
- 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'
2026-03-18 08:13:58 +08:00

286 lines
8.8 KiB
TypeScript

"use client";
import { LightButton, PrimaryButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Select } from "@/design-system/base/select";
import { Textarea } from "@/design-system/base/textarea";
import { Modal } from "@/design-system/overlay/modal";
import { VStack, HStack } from "@/design-system/layout/stack";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { actionCreateCard } from "@/modules/card/card-action";
import type { CardType, CardMeaning } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
const QUERY_LANGUAGE_LABELS = {
english: "english",
chinese: "chinese",
japanese: "japanese",
korean: "korean",
} as const;
const QUERY_LANGUAGES = [
{ value: "en", label: "english" as const },
{ value: "zh", label: "chinese" as const },
{ value: "ja", label: "japanese" as const },
{ value: "ko", label: "korean" as const },
] as const;
interface AddCardModalProps {
isOpen: boolean;
onClose: () => void;
deckId: number;
onAdded: () => void;
}
export function AddCardModal({
isOpen,
onClose,
deckId,
onAdded,
}: AddCardModalProps) {
const t = useTranslations("deck_id");
const [cardType, setCardType] = useState<CardType>("WORD");
const [word, setWord] = useState("");
const [ipa, setIpa] = useState("");
const [queryLang, setQueryLang] = useState("en");
const [customQueryLang, setCustomQueryLang] = useState("");
const [meanings, setMeanings] = useState<CardMeaning[]>([
{ partOfSpeech: null, definition: "", example: null }
]);
const [isSubmitting, setIsSubmitting] = useState(false);
const showIpa = cardType === "WORD" || cardType === "PHRASE";
const addMeaning = () => {
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
};
const removeMeaning = (index: number) => {
if (meanings.length > 1) {
setMeanings(meanings.filter((_, i) => i !== index));
}
};
const updateMeaning = (
index: number,
field: "partOfSpeech" | "definition" | "example",
value: string
) => {
const updated = [...meanings];
updated[index] = {
...updated[index],
[field]: value || null
};
setMeanings(updated);
};
const resetForm = () => {
setCardType("WORD");
setWord("");
setIpa("");
setQueryLang("en");
setCustomQueryLang("");
setMeanings([{ partOfSpeech: null, definition: "", example: null }]);
};
const handleAdd = async () => {
if (!word.trim()) {
toast.error(t("wordRequired"));
return;
}
const validMeanings = meanings.filter(m => m.definition?.trim());
if (validMeanings.length === 0) {
toast.error(t("definitionRequired"));
return;
}
setIsSubmitting(true);
const effectiveQueryLang = customQueryLang.trim() || queryLang;
try {
const cardResult = await actionCreateCard({
deckId,
word: word.trim(),
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
queryLang: effectiveQueryLang,
cardType,
meanings: validMeanings.map(m => ({
partOfSpeech: cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
definition: m.definition!.trim(),
example: m.example?.trim() || null,
})),
});
if (!cardResult.success) {
throw new Error(cardResult.message || "Failed to create card");
}
resetForm();
onAdded();
onClose();
toast.success(t("cardAdded") || "Card added successfully");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
resetForm();
onClose();
};
return (
<Modal open={isOpen} onClose={handleClose} size="md">
<Modal.Header>
<Modal.Title>{t("addNewCard")}</Modal.Title>
<Modal.CloseButton onClick={handleClose} />
</Modal.Header>
<Modal.Body className="space-y-4">
<HStack gap={3}>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("cardType")}
</label>
<Select
value={cardType}
onChange={(e) => setCardType(e.target.value as CardType)}
className="w-full"
>
<option value="WORD">{t("wordCard")}</option>
<option value="PHRASE">{t("phraseCard")}</option>
<option value="SENTENCE">{t("sentenceCard")}</option>
</Select>
</div>
</HStack>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("queryLang")}
</label>
<HStack gap={2} className="flex-wrap">
{QUERY_LANGUAGES.map((lang) => (
<LightButton
key={lang.value}
selected={!customQueryLang && queryLang === lang.value}
onClick={() => {
setQueryLang(lang.value);
setCustomQueryLang("");
}}
size="sm"
>
{t(lang.label)}
</LightButton>
))}
<Input
value={customQueryLang}
onChange={(e) => setCustomQueryLang(e.target.value)}
placeholder={t("enterLanguageName")}
className="w-auto min-w-[100px] flex-1"
size="sm"
/>
</HStack>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{cardType === "SENTENCE" ? t("sentence") : t("word")} *
</label>
<Input
value={word}
onChange={(e) => setWord(e.target.value)}
className="w-full"
placeholder={cardType === "SENTENCE" ? t("sentencePlaceholder") : t("wordPlaceholder")}
/>
</div>
{showIpa && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("ipa")}
</label>
<Input
value={ipa}
onChange={(e) => setIpa(e.target.value)}
className="w-full"
placeholder={t("ipaPlaceholder")}
/>
</div>
)}
<div>
<HStack justify="between" className="mb-2">
<label className="block text-sm font-medium text-gray-700">
{t("meanings")} *
</label>
<button
onClick={addMeaning}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus size={14} />
{t("addMeaning")}
</button>
</HStack>
<VStack gap={4}>
{meanings.map((meaning, index) => (
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
<HStack gap={2}>
{cardType !== "SENTENCE" && (
<div className="w-28 shrink-0">
<Input
value={meaning.partOfSpeech || ""}
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
placeholder={t("partOfSpeech")}
className="w-full"
/>
</div>
)}
<div className="flex-1">
<Input
value={meaning.definition || ""}
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
placeholder={t("definition")}
className="w-full"
/>
</div>
{meanings.length > 1 && (
<button
onClick={() => removeMeaning(index)}
className="p-2 text-gray-400 hover:text-red-500"
>
<Trash2 size={16} />
</button>
)}
</HStack>
<Textarea
value={meaning.example || ""}
onChange={(e) => updateMeaning(index, "example", e.target.value)}
placeholder={t("examplePlaceholder")}
className="w-full min-h-[40px] text-sm"
/>
</div>
))}
</VStack>
</div>
</Modal.Body>
<Modal.Footer>
<LightButton onClick={handleClose}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleAdd} loading={isSubmitting}>
{isSubmitting ? t("adding") : t("add")}
</PrimaryButton>
</Modal.Footer>
</Modal>
);
}