@nualang/nualang-ui-components 0.1.1412 → 0.1.1413

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,14 @@
1
- import { useState, useEffect, forwardRef, useMemo, createElement } from "react";
2
- import { TextField, MenuItem, ListSubheader, Box, Paper, Typography } from "@mui/material";
3
- import { ForumRounded, QuizRounded, ImageRounded, SentimentSatisfiedRounded, CheckRounded } from "@mui/icons-material";
1
+ import { useState, useEffect, forwardRef, createElement } from "react";
2
+ import { TextField, MenuItem, ListSubheader, Box, Paper, Typography, Grid, Button, Avatar } from "@mui/material";
3
+ import { ForumRounded, QuizRounded, ImageRounded, SentimentSatisfiedRounded, CheckRounded, Person } from "@mui/icons-material";
4
4
  import { useTheme, alpha } from "@mui/material/styles";
5
5
  import { keyframes } from "@emotion/react";
6
6
  import ResponsiveDialog from "../ResponsiveDialog/ResponsiveDialog";
7
7
  import ChatbotNualaAnimation from "../../Misc/QuickCreateNualaAnimations/ChatbotNualaAnimation";
8
+ import VoiceSelector from "../../Forms/VoiceSelector/VoiceSelector";
9
+ import { findVoice, pickRandomVoice } from "../../utils/voices";
10
+ import { makeStyles } from "tss-react/mui";
11
+ import AvatarSelector from "../../Misc/AvatarSelector/AvatarSelector";
8
12
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
13
  export const BOT_NAME_MAX_LENGTH = 100;
10
14
  const peekAnimation = keyframes`
@@ -14,6 +18,18 @@ const peekAnimation = keyframes`
14
18
  67% { transform: translateX(-150px) rotate(75deg); }
15
19
  100% { transform: translateX(-150px) rotate(75deg); }
16
20
  `;
21
+ const useStyles = makeStyles()(theme => ({
22
+ avatar: {
23
+ width: 70,
24
+ height: 70,
25
+ marginBottom: theme.spacing(2),
26
+ cursor: "pointer"
27
+ },
28
+ avatarIcon: {
29
+ width: "80%",
30
+ height: "80%"
31
+ }
32
+ }));
17
33
  const ChatbotDialogPaper = /*#__PURE__*/forwardRef(function ChatbotDialogPaper({
18
34
  children,
19
35
  className,
@@ -378,7 +394,11 @@ export default function QuickCreateChatbotDialog({
378
394
  topicDifficulty,
379
395
  topicPicture,
380
396
  defaultNumberOfQuestions = 5,
381
- voices = []
397
+ voices = [],
398
+ handleSpeak,
399
+ isUserInternal = false,
400
+ avatars,
401
+ learnLang
382
402
  }) {
383
403
  const [botTitle, setBotTitle] = useState("");
384
404
  const [difficulty, setDifficulty] = useState(topicDifficulty || "");
@@ -387,6 +407,14 @@ export default function QuickCreateChatbotDialog({
387
407
  const [riveFile, setRiveFile] = useState("");
388
408
  const [numberOfQuestions, setNumberOfQuestions] = useState(defaultNumberOfQuestions);
389
409
  const [isLoading, setIsLoading] = useState(false);
410
+ const [selectedVoice, setSelectedVoice] = useState(null);
411
+ const [selectedVoiceLanguageCode, setSelectedVoiceLanguageCode] = useState(null);
412
+ const [selectedVoicePitch, setSelectedVoicePitch] = useState(null);
413
+ const [botPicture, setBotPicture] = useState("");
414
+ const [avatarSelector, setAvatarSelector] = useState(false);
415
+ const {
416
+ classes
417
+ } = useStyles();
390
418
 
391
419
  // Keep the questions field in sync if the default changes while closed.
392
420
  useEffect(() => {
@@ -408,31 +436,31 @@ export default function QuickCreateChatbotDialog({
408
436
  setTemplateId("");
409
437
  setNumberOfQuestions(defaultNumberOfQuestions);
410
438
  handleClose();
439
+ // Cleared so the next open seeds a fresh random voice.
440
+ setSelectedVoice(null);
441
+ setSelectedVoiceLanguageCode(null);
442
+ setSelectedVoicePitch(null);
443
+ setBotPicture("");
444
+ setAvatarSelector(false);
445
+ };
446
+ const handleAvatarSelection = value => {
447
+ setBotPicture(value);
448
+ setAvatarSelector(false);
411
449
  };
412
- const voicesArray = useMemo(() => {
413
- return Object.entries(voices).flatMap(([key, dialect]) => {
414
- if (!dialect?.voiceOptions || !Array.isArray(dialect.voiceOptions)) {
415
- return [];
416
- }
417
- return dialect.voiceOptions.filter(voice => voice?.name) // Validate required properties
418
- .map(voice => ({
419
- voice,
420
- dialect,
421
- key
422
- }));
423
- });
424
- }, [voices]);
425
450
 
426
- // exclude legacy for random selection
427
- const nonLegacyVoicePool = voicesArray.filter(v => !v.voice?.legacy && !v.legacy);
428
- const voicePool = nonLegacyVoicePool.length > 0 ? nonLegacyVoicePool : voicesArray;
429
- const randomIndex = Math.floor(Math.random() * voicePool.length);
430
- const randomVoiceObj = voicePool[randomIndex] || {};
431
- const {
432
- voice: randomVoice,
433
- dialect
434
- } = randomVoiceObj;
435
- const hasAvailableVoice = Boolean(randomVoice?.name && dialect?.languageCode);
451
+ // ActorForm assigns each actor a random voice on mount; standalone, the same
452
+ // seeding happens here each time the dialog opens with nothing chosen yet.
453
+ useEffect(() => {
454
+ if (!open || selectedVoice) return;
455
+ const picked = pickRandomVoice(voices);
456
+ if (!picked) return;
457
+ setSelectedVoice(picked.voice.name);
458
+ setSelectedVoiceLanguageCode(picked.dialect.languageCode);
459
+ setSelectedVoicePitch(null);
460
+ setBotPicture(picked.voice.userImage || "");
461
+ }, [open, selectedVoice, voices]);
462
+ const selectedVoiceOption = findVoice(voices, selectedVoice)?.voice;
463
+ const hasAvailableVoice = Boolean(selectedVoice && selectedVoiceLanguageCode);
436
464
  const isValid = Boolean(botTitle.trim()) && Boolean(difficulty) && Boolean(templateId) && Boolean(template && template.botId === templateId) && Number(numberOfQuestions) > 0 && hasAvailableVoice;
437
465
  const handleSubmit = async () => {
438
466
  if (!isValid) return;
@@ -449,10 +477,11 @@ export default function QuickCreateChatbotDialog({
449
477
  selectedTemplate: templateId,
450
478
  numberOfQuestions: Number(numberOfQuestions),
451
479
  courseSectionTopicId: `${courseId}|${sectionId}|${topicId}`,
452
- voice: randomVoice.name,
453
- voiceLanguageCode: dialect.languageCode,
454
- name: randomVoice.name,
455
- botAvatar: randomVoice.userImage,
480
+ voice: selectedVoice,
481
+ voiceLanguageCode: selectedVoiceLanguageCode,
482
+ name: selectedVoice,
483
+ voicePitch: selectedVoicePitch,
484
+ botAvatar: botPicture,
456
485
  isChallengeBot: false,
457
486
  isDynamicResponsesEnabled: "enable",
458
487
  picture: topicPicture || template.picture
@@ -467,7 +496,7 @@ export default function QuickCreateChatbotDialog({
467
496
  setIsLoading(false);
468
497
  }
469
498
  };
470
- return /*#__PURE__*/_jsx(ResponsiveDialog, {
499
+ return /*#__PURE__*/_jsxs(ResponsiveDialog, {
471
500
  open: open,
472
501
  handleClose: isLoading ? undefined : resetAndClose,
473
502
  handleSubmit: handleSubmit,
@@ -476,7 +505,7 @@ export default function QuickCreateChatbotDialog({
476
505
  closeText: t("cancel") || "Cancel",
477
506
  submitText: t("create") || "Create",
478
507
  PaperComponent: ChatbotDialogPaper,
479
- children: /*#__PURE__*/_jsxs(Box, {
508
+ children: [/*#__PURE__*/_jsxs(Box, {
480
509
  display: "flex",
481
510
  flexDirection: "column",
482
511
  gap: 1,
@@ -537,7 +566,90 @@ export default function QuickCreateChatbotDialog({
537
566
  },
538
567
  onKeyDown: e => e.key === "Enter" && handleSubmit(),
539
568
  margin: "normal"
569
+ }), /*#__PURE__*/_jsxs(Grid, {
570
+ size: 12,
571
+ sx: {
572
+ mt: 1
573
+ },
574
+ children: [botPicture ? /*#__PURE__*/_jsx(Avatar, {
575
+ "aria-label": selectedVoiceOption?.name,
576
+ src: botPicture,
577
+ className: classes.avatar,
578
+ onClick: () => setAvatarSelector(true),
579
+ alt: ""
580
+ }) : /*#__PURE__*/_jsx(Avatar, {
581
+ "aria-label": selectedVoiceOption?.name,
582
+ sx: theme => ({
583
+ backgroundColor: theme.palette.mode === "dark" ? "white" : "",
584
+ color: theme.palette.mode === "dark" ? theme.palette.background.default : ""
585
+ }),
586
+ className: classes.avatar,
587
+ onClick: () => setAvatarSelector(true),
588
+ alt: "",
589
+ children: /*#__PURE__*/_jsx(Person, {
590
+ className: classes.avatarIcon
591
+ })
592
+ }), /*#__PURE__*/_jsx(Button, {
593
+ color: "primary",
594
+ component: "span",
595
+ onClick: () => setAvatarSelector(true),
596
+ children: t("choose_avatar")
597
+ }), /*#__PURE__*/_jsx(VoiceSelector, {
598
+ id: "voiceSelector",
599
+ "data-cy": "voiceSelector",
600
+ name: "voiceSelector",
601
+ label: t("bot_voice"),
602
+ value: selectedVoice || "",
603
+ margin: "normal",
604
+ variant: "outlined",
605
+ fullWidth: true,
606
+ required: learnLang !== "irish",
607
+ t: t,
608
+ voices: voices,
609
+ onChange: e => {
610
+ if (e && e.target && e.target.value) {
611
+ const selectedVoice = e.target.value;
612
+ const languageCode = e.target.languageCode;
613
+ setSelectedVoice(selectedVoice);
614
+ handleSpeak?.({
615
+ text: selectedVoice,
616
+ learnLanguage: learnLang,
617
+ voiceName: selectedVoice,
618
+ languageCode
619
+ });
620
+ setSelectedVoiceLanguageCode(languageCode);
621
+ setSelectedVoicePitch(null);
622
+ setBotPicture(e.target.picture);
623
+ }
624
+ },
625
+ onVoicePitchChange: e => {
626
+ if (e && e.target) {
627
+ setSelectedVoicePitch(e.target.value);
628
+ handleSpeak?.({
629
+ text: selectedVoiceLanguageCode ? t("voice_pitch_example", {
630
+ name: selectedVoice,
631
+ lng: selectedVoiceLanguageCode?.substring(0, 2)
632
+ }) : selectedVoice,
633
+ learnLanguage: learnLang,
634
+ voiceName: selectedVoice,
635
+ languageCode: selectedVoiceLanguageCode,
636
+ pitch: e.target.value
637
+ });
638
+ }
639
+ },
640
+ voicePitch: selectedVoicePitch,
641
+ isUserInternal: isUserInternal,
642
+ handleSpeak: handleSpeak,
643
+ learnLang: learnLang
644
+ })]
540
645
  })]
541
- })
646
+ }), /*#__PURE__*/_jsx(AvatarSelector, {
647
+ t: t,
648
+ open: avatarSelector,
649
+ avatars: avatars,
650
+ picture: botPicture,
651
+ handleClose: () => setAvatarSelector(false),
652
+ handleSubmit: value => handleAvatarSelection(value)
653
+ })]
542
654
  });
543
655
  }
@@ -330,7 +330,6 @@ export default function QuickCreateScenarioDialog({
330
330
  isUserInternal: isUserInternal,
331
331
  handleSpeak: handleSpeak,
332
332
  avatars: avatars,
333
- hidePitch: true,
334
333
  disabled: isLoading
335
334
  })
336
335
  }), /*#__PURE__*/_jsx(Grid, {
@@ -322,7 +322,7 @@ function ListenAndSpeak({
322
322
  checked,
323
323
  setChecked,
324
324
  setIsAssignmentExercise,
325
- isTourFinished
325
+ definitionText
326
326
  } = exerciseState;
327
327
  useEffect(() => {
328
328
  if (currentPhrase?.phrase) {
@@ -435,18 +435,6 @@ function ListenAndSpeak({
435
435
  question = questions[0];
436
436
  image = question.image || null;
437
437
  }
438
- const definitionText = (() => {
439
- if (!question) return "";
440
- if (question.definitions?.length > 0) {
441
- const raw = question.definitions[0];
442
- return typeof raw === "string" ? raw : "";
443
- }
444
- if (question.translations?.length > 0) {
445
- const raw = question.translations[0];
446
- return typeof raw === "string" ? raw : "";
447
- }
448
- return "";
449
- })();
450
438
  const handleMoreTranslations = () => {
451
439
  setViewMoreTranslations(true);
452
440
  };
@@ -471,11 +459,6 @@ function ListenAndSpeak({
471
459
  const goBackToAssignment = () => {
472
460
  navigate(`/classrooms/${classroomId}#Assignments`);
473
461
  };
474
- useEffect(() => {
475
- if (open && definitionText && definitionText !== "" && !isCompletionModalOpen && !isCloseModalOpen && !isInProgressModalOpen && learnLang && learnLang !== "irish" && !muteSound && isTourFinished) {
476
- speak(definitionText, voice, false, "#currentPhrase", voiceLanguageCode, false, voicePitch);
477
- }
478
- }, [currentPhrase, open, isTourFinished]);
479
462
  const NualaSpeakingAnimation = (voiceMap.get(voice) || {}).speakingImage || null;
480
463
  return /*#__PURE__*/_jsxs(Dialog, {
481
464
  fullScreen: true,
@@ -217,7 +217,7 @@ function Meaning({
217
217
  handleCloseClicked,
218
218
  handleTranslationTourFinished,
219
219
  setIsAssignmentExercise,
220
- isTourFinished
220
+ definitionText
221
221
  } = exerciseState;
222
222
  const [selectedPhrase, setSelectedPhrase] = useState(null);
223
223
  const [phraseOptions, setPhraseOptions] = useState([]);
@@ -353,18 +353,6 @@ function Meaning({
353
353
  question = questions[0];
354
354
  image = question.image || null;
355
355
  }
356
- const definitionText = (() => {
357
- if (!question) return "";
358
- if (question.definitions?.length > 0) {
359
- const raw = question.definitions[0];
360
- return typeof raw === "string" ? raw : "";
361
- }
362
- if (question.translations?.length > 0) {
363
- const raw = question.translations[0];
364
- return typeof raw === "string" ? raw : "";
365
- }
366
- return "";
367
- })();
368
356
  const {
369
357
  voice,
370
358
  voiceLanguageCode,
@@ -410,11 +398,6 @@ function Meaning({
410
398
  navigate(`/classrooms/${classroomId}#Assignments`);
411
399
  };
412
400
  const isSubmitDisabled = !selectedPhrase || showResult;
413
- useEffect(() => {
414
- if (open && definitionText && definitionText !== "" && !isCompletionModalOpen && !isCloseModalOpen && !isInProgressModalOpen && learnLang && learnLang !== "irish" && !isReadWriteModeStudent && !muteSound && isTourFinished) {
415
- speak(definitionText, voice, false, "#currentPhrase", voiceLanguageCode, false, voicePitch);
416
- }
417
- }, [currentPhrase, open, isTourFinished]);
418
401
  const NualaSpeakingAnimation = (voiceMap.get(voice) || {}).speakingImage || null;
419
402
  return /*#__PURE__*/_jsxs(Dialog, {
420
403
  fullScreen: true,
@@ -143,18 +143,40 @@ function Transition(props) {
143
143
  ...props
144
144
  });
145
145
  }
146
+ const getMatchingTranslationWords = (translationWords, translations, removeExtraWhiteSpaces) => {
147
+ if (!translations || !translations.length) return [];
148
+ const splitTranslations = translations.map(translation => removeExtraWhiteSpaces(translation ?? "").split(" "));
149
+ const attemptWords = translationWords.map(word => removeAllSymbols(word.text).toLowerCase());
150
+ const attemptJoined = attemptWords.join(" ");
151
+ const exactMatchIndex = splitTranslations.findIndex(words => words.map(w => removeAllSymbols(w).toLowerCase()).join(" ") === attemptJoined);
152
+ if (exactMatchIndex > -1) {
153
+ return splitTranslations[exactMatchIndex];
154
+ }
155
+
156
+ // No exact match (attempt is incomplete or wrong) - fall back to whichever
157
+ // translation shares the most words with the attempt at the same
158
+ // position, so feedback is still as helpful as possible.
159
+ let bestIndex = 0;
160
+ let bestScore = -1;
161
+ splitTranslations.forEach((words, translationIndex) => {
162
+ const score = attemptWords.filter((word, wordIndex) => words[wordIndex] && removeAllSymbols(words[wordIndex]).toLowerCase() === word).length;
163
+ if (score > bestScore) {
164
+ bestScore = score;
165
+ bestIndex = translationIndex;
166
+ }
167
+ });
168
+ return splitTranslations[bestIndex];
169
+ };
146
170
  const WordBankButton = /*#__PURE__*/forwardRef(({
147
171
  word,
148
172
  index,
149
- question,
150
- removeExtraWhiteSpaces,
173
+ translationWordList,
151
174
  translationWords,
152
175
  setTranslationWords,
153
176
  classes,
154
177
  handleSelectInAnswerBox,
155
178
  checkAnswer
156
179
  }, ref) => {
157
- const translationWordList = question && question.translations && question.translations.length ? removeExtraWhiteSpaces(question.translations[0]).split(" ") : [];
158
180
  const key = `${word.text}${word.id}`;
159
181
  const isWordCorrect = translationWordList[index] && removeAllSymbols(word.text) === removeAllSymbols(translationWordList[index]);
160
182
  const onButtonClick = () => {
@@ -495,7 +517,7 @@ function Translator({
495
517
  question = questions[0];
496
518
  image = question.image || null;
497
519
  }
498
- const translationWordList = question && question.translations && question.translations.length ? removeExtraWhiteSpaces(question.translations[0]).split(" ") : [];
520
+ const translationWordList = question && question.translations && question.translations.length ? getMatchingTranslationWords(translationWords, question.translations, removeExtraWhiteSpaces) : [];
499
521
  const {
500
522
  voice,
501
523
  voiceLanguageCode,
@@ -798,9 +820,7 @@ function Translator({
798
820
  "data-cy": "word-bank-button",
799
821
  word: word,
800
822
  index: index,
801
- question: question,
802
- currentPhrase: currentPhrase,
803
- removeExtraWhiteSpaces: removeExtraWhiteSpaces,
823
+ translationWordList: translationWordList,
804
824
  translationWords: translationWords,
805
825
  setTranslationWords: setTranslationWords,
806
826
  classes: classes,