Compare commits
47 Commits
speaking-ui
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4076776d7d | |||
| 2da4bb213e | |||
| 966c6a271b | |||
| 354cb6f322 | |||
| eb54b0470d | |||
| a5ecfde9db | |||
| 61fa2495e4 | |||
| 9f65f9e935 | |||
| 82460da4e5 | |||
| 1574fcce0c | |||
| 2145b08e16 | |||
| 1397e50912 | |||
| e49e9b2c8e | |||
| 7935b78ce4 | |||
| b2f0746ec4 | |||
| 28eae67f9e | |||
| 177139f9f5 | |||
| d7dee43d81 | |||
| 3f5751f59f | |||
| 83b89ea942 | |||
| f3cbca4b73 | |||
| f89cf12ab3 | |||
| 514fba236c | |||
| 5c6fc7a123 | |||
| 43fa157687 | |||
| f0efdb556f | |||
| b88b0fd1f4 | |||
| 4c293e463e | |||
| 9c7f8576b8 | |||
| 56d51ca32f | |||
| 6dcab68366 | |||
| 3963f9c37a | |||
| 45d9e4bcb7 | |||
| 85fe1b807f | |||
| f3588e4831 | |||
| 72db21192f | |||
| f737889799 | |||
| 492589fe49 | |||
| a5a8b13a6b | |||
| 6a21716ec6 | |||
| 8f94bdf73a | |||
| d89c91b643 | |||
| 2eab4e97af | |||
| 4e8a4acaae | |||
| a34671434b | |||
| 498d188925 | |||
| 1ff4c1cf1b |
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Install and build
|
||||
run: |
|
||||
yarn install
|
||||
yarn build
|
||||
CI=false yarn build
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-spinners": "^0.13.8",
|
||||
"sass": "^1.69.5",
|
||||
"typescript": "^4.4.2",
|
||||
"web-vitals": "^2.1.0"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,26 @@
|
||||
import DuoSplash from "../assets/img/duo-splash.png";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface LessonCompleteProps {
|
||||
home: string;
|
||||
}
|
||||
|
||||
export default function LessonComplete({ home } : LessonCompleteProps)
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <div className="flex flex-col justify-center">
|
||||
<div className="flex flex-col p-5 gap-5 items-center">
|
||||
<img src={DuoSplash} alt="Duolingo Logo"></img>
|
||||
|
||||
<h1>Well done!</h1>
|
||||
|
||||
<p className="white">
|
||||
Experience Gained: +100XP
|
||||
</p>
|
||||
<button className="green" onClick={() => navigate(home)}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// NavBar.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { NavItem } from '../components/NavItem';
|
||||
import { NavItem } from './NavItem';
|
||||
|
||||
export default function NavBar() {
|
||||
const navigate = useNavigate();
|
||||
@@ -18,7 +18,7 @@ export default function NavBar() {
|
||||
|
||||
const navItems = [
|
||||
{ icon: "mdi:book", label: "Courses", path: "/courses" },
|
||||
{ icon: "mdi:earth", label: "Collab Learning", path: "/collab-learning" },
|
||||
{ icon: "mdi:earth", label: "Chat", path: "/collab-learning" },
|
||||
{ icon: "mdi:clipboard-text-clock", label: "Review", path: "/review" },
|
||||
{ icon: "mdi:microphone-message", label: "Speaking", path: "/speaking" },
|
||||
{ icon: "mdi:account", label: "Profile", path: "/profile" },
|
||||
@@ -38,4 +38,4 @@ export default function NavBar() {
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {speechToText, getAIMarking, getLanguage} from '../logic/sdk';
|
||||
import ClipLoader from "react-spinners/ClipLoader";
|
||||
import {VerbalPronunciation} from "../logic/CourseData";
|
||||
|
||||
interface VerbalPronunciationProps {
|
||||
q: VerbalPronunciation;
|
||||
chapter: string;
|
||||
onSubmit: Function;
|
||||
}
|
||||
|
||||
export default function VerbalPronunciationExercise({q, chapter, onSubmit}: VerbalPronunciationProps) {
|
||||
const language = getLanguage().name;
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [correct, setCorrect] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [userAnswer, setUserAnswer] = useState("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (answered) {
|
||||
setAnswered(false);
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
|
||||
let chunks = [] as any;
|
||||
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
||||
mediaRecorder.current = new MediaRecorder(stream);
|
||||
mediaRecorder.current.ondataavailable = (e) => {
|
||||
chunks.push(e.data);
|
||||
}
|
||||
|
||||
mediaRecorder.current.onstop = async (e) => {
|
||||
setIsRecording(false);
|
||||
setLoading(true);
|
||||
const blob = new Blob(chunks, { type: 'audio/wav' });
|
||||
chunks = [];
|
||||
|
||||
const audioFile = new File([blob], "audio.wav", { type: 'audio/wav' });
|
||||
|
||||
const text = await speechToText(audioFile);
|
||||
setUserAnswer(text);
|
||||
const aiMark = await getAIMarking(`Please pronounce the following: ${q.question}`, text.toLowerCase(), "", chapter, language);
|
||||
setAnswered(true);
|
||||
setCorrect(aiMark.correct);
|
||||
setReason(aiMark.reason);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRecord = useCallback(() => {
|
||||
if (!isRecording) {
|
||||
if (mediaRecorder.current) {
|
||||
mediaRecorder.current.start();
|
||||
}
|
||||
setIsRecording(true);
|
||||
} else {
|
||||
if (mediaRecorder.current) {
|
||||
mediaRecorder.current.stop();
|
||||
}
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
const ResponseSection = (correct: string | null, reason: string | null) => {
|
||||
if (!answered) {
|
||||
return (
|
||||
<div className='flex backdrop:flex-row justify-center w-full'>
|
||||
<Icon icon="mdi:microphone" className="microphone h-20 w-20 mx-auto"/>
|
||||
<button className={`record-btn mx-auto ${isRecording ? 'red' : ''}`} onClick={handleRecord}>
|
||||
{isRecording ? 'Stop Recording'
|
||||
: loading ?
|
||||
<ClipLoader
|
||||
color="white"
|
||||
loading={loading}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
/> :
|
||||
'Record'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className='flex flex-wrap w-full gap-5'>
|
||||
<div className="font-bold">{correct ? "Correct!" : "Incorrect"}</div>
|
||||
<div>{reason}</div>
|
||||
<button className='green w-full' onClick={() => handleSubmit()}>{!answered ? "Submit" : "Continue"}</button>
|
||||
{answered && !correct && <button className='red w-full' onClick={() => handleSubmit()}>I was right</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="v-layout flex justify-center h-full">
|
||||
<div className="font-bold">Say the following</div>
|
||||
<div className='box'>
|
||||
{q.question}
|
||||
</div>
|
||||
{userAnswer && <div className='flex items-center gap-3'>
|
||||
<Icon icon="mdi:microphone"/> {userAnswer}
|
||||
</div>}
|
||||
<div className="flex-1"></div>
|
||||
{ResponseSection(correct, reason)}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import {getAIMarking, getLanguage} from '../logic/sdk';
|
||||
import ClipLoader from "react-spinners/ClipLoader";
|
||||
import { Icon } from '@iconify/react';
|
||||
import {VerbalQuestion} from "../logic/CourseData";
|
||||
|
||||
interface VerbalQuestionProps {
|
||||
q: VerbalQuestion
|
||||
chapter: string;
|
||||
onSubmit: Function;
|
||||
}
|
||||
|
||||
export default function VerbalQuestionsExercise({q, chapter, onSubmit}: VerbalQuestionProps) {
|
||||
const language = getLanguage().name;
|
||||
const [selectedWords, setSelectedWords] = useState<string[]>([]);
|
||||
const [remainingWords, setRemainingWords] = useState(q.wordBank);
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [correct, setCorrect] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleWordBankClick = (word: string) => {
|
||||
setSelectedWords([...selectedWords, word]);
|
||||
setRemainingWords(remainingWords.filter((w) => w !== word));
|
||||
}
|
||||
|
||||
const handleSelectedWordClick = (word: string) => {
|
||||
setRemainingWords([...remainingWords, word]);
|
||||
setSelectedWords(selectedWords.filter((w) => w !== word));
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const userAnswer = selectedWords.join("");
|
||||
if (answered) {
|
||||
setAnswered(false);
|
||||
onSubmit();
|
||||
} else {
|
||||
setLoading(true);
|
||||
getAIMarking(
|
||||
q.question,
|
||||
userAnswer.toLowerCase(),
|
||||
q.expected.toLowerCase(),
|
||||
chapter,
|
||||
language
|
||||
).then((res) => {
|
||||
setLoading(false);
|
||||
setAnswered(true);
|
||||
setCorrect(res.correct);
|
||||
setReason(res.reason);
|
||||
}).catch((e) => console.error(e));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const ResponseSection = (correct: string | null, reason: string | null) => {
|
||||
if (!answered) {
|
||||
return (
|
||||
<div className='w-full h-36'>
|
||||
<div className=' flex-row flex-wrap w-full h-full'>
|
||||
{remainingWords.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="border-gray-300 border-2 m-1 p-1 px-3 rounded-xl inline-block cursor-pointer"
|
||||
onClick={() => handleWordBankClick(word)}>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button className='green' onClick={() => handleSubmit()}>
|
||||
{loading ? <ClipLoader
|
||||
color="white"
|
||||
loading={loading}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
/> : !answered ? "Submit" : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
)
|
||||
} else {
|
||||
return <div className='flex flex-wrap w-full gap-5'>
|
||||
<div className="font-bold">{correct ? "Correct!" : "Incorrect"}</div>
|
||||
<div>{reason}</div>
|
||||
<button className='green w-full' onClick={() => handleSubmit()}>{!answered ? "Submit" : "Continue"}</button>
|
||||
{answered && !correct && <button className='red w-full' onClick={() => handleSubmit()}>I was right</button>}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='v-layout space-y-8 items-center w-full'>
|
||||
<h2>What do you hear?</h2>
|
||||
<audio src={q.url} controls className='audio-player'></audio>
|
||||
<div className='flex-row flex-wrap border-b-4 w-full h-32'>
|
||||
{selectedWords.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="border-gray-300 border-b-2 border-2 m-1 p-1 px-3 rounded-xl inline-block cursor-pointer"
|
||||
onClick={(e) => handleSelectedWordClick(word)}>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{ResponseSection(correct, reason)}
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { getAIMarking, getLanguage } from '../logic/sdk';
|
||||
import ClipLoader from "react-spinners/ClipLoader";
|
||||
import { VideoQuestion } from "../logic/CourseData";
|
||||
|
||||
interface VideoQuestionProps {
|
||||
q: VideoQuestion;
|
||||
chapter: string;
|
||||
onSubmit: Function;
|
||||
}
|
||||
|
||||
export default function VideoExercise({q, chapter, onSubmit}: VideoQuestionProps) {
|
||||
const language = getLanguage();
|
||||
const [userAnswer, setUserAnswer] = useState("");
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [correct, setCorrect] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUserAnswer(e.target.value);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (answered) {
|
||||
setAnswered(false);
|
||||
onSubmit();
|
||||
} else {
|
||||
setLoading(true);
|
||||
getAIMarking(
|
||||
`Please watch the video and answer the question: "${q.question}".`,
|
||||
userAnswer.toLowerCase(),
|
||||
`${q.expected} (For the grader, here is the video description: ${q.description}. And please accept any reasonable answer in both English and ${language.name}).`,
|
||||
chapter,
|
||||
language.name
|
||||
).then((res) => {
|
||||
setLoading(false);
|
||||
setAnswered(true);
|
||||
setCorrect(res.correct);
|
||||
setReason(res.reason);
|
||||
}).catch((e) => console.error(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='v-layout w-full h-full non-center'>
|
||||
<div className="box">
|
||||
{q.question}
|
||||
</div>
|
||||
<video src={q.clipUrl} controls className='video-player'></video>
|
||||
<div className="flex-1"></div>
|
||||
<div className='flex-col w-full'>
|
||||
{!answered ?
|
||||
<div className="v-layout">
|
||||
<input type="text" value={userAnswer} onChange={handleChange}
|
||||
placeholder="Type your answer here"/>
|
||||
<button className='green' onClick={() => handleSubmit()}>
|
||||
{loading ? <ClipLoader color="white" loading={loading} /> : "Submit"}
|
||||
</button>
|
||||
</div> :
|
||||
<div className='flex flex-wrap w-full gap-5'>
|
||||
<div className="font-bold">{correct ? "Correct!" : "Incorrect"}</div>
|
||||
<div>{reason}</div>
|
||||
<button className='green w-full' onClick={() => handleSubmit()}>{!answered ? "Submit" : "Continue"}</button>
|
||||
{answered && !correct && <button className='red w-full' onClick={() => handleSubmit()}>I was right</button>}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import {getAIMarking, getLanguage} from '../logic/sdk';
|
||||
import ClipLoader from "react-spinners/ClipLoader";
|
||||
import {WrittenQuestion} from "../logic/CourseData";
|
||||
|
||||
interface WrittenQuestionProps {
|
||||
q: WrittenQuestion
|
||||
chapter: string;
|
||||
onSubmit: Function;
|
||||
}
|
||||
|
||||
export default function WrittenQuestionExercise({q, chapter, onSubmit}: WrittenQuestionProps) {
|
||||
const language = getLanguage();
|
||||
const [selectedWords, setSelectedWords] = useState<string[]>([]);
|
||||
const [remainingWords, setRemainingWords] = useState(q.wordBank);
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [correct, setCorrect] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleWordBankClick = (word: string) => {
|
||||
setSelectedWords([...selectedWords, word]);
|
||||
setRemainingWords(remainingWords.filter((w) => w !== word));
|
||||
}
|
||||
|
||||
const handleSelectedWordClick = (word: string) => {
|
||||
setRemainingWords([...remainingWords, word]);
|
||||
setSelectedWords(selectedWords.filter((w) => w !== word));
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
// TODO: This space join isn't correct.
|
||||
// When the word bank is Japanese or Chinese, the words are not separated by spaces.
|
||||
// When the langauge is English, the words are separated by spaces.
|
||||
// We need to figure out how to handle this.
|
||||
const userAnswer = selectedWords.join(" ");
|
||||
if (answered) {
|
||||
setAnswered(false);
|
||||
onSubmit();
|
||||
} else {
|
||||
setLoading(true);
|
||||
getAIMarking(
|
||||
q.question,
|
||||
userAnswer.toLowerCase(),
|
||||
q.expected.toLowerCase(),
|
||||
chapter,
|
||||
language.name
|
||||
).then((res) => {
|
||||
setLoading(false);
|
||||
setAnswered(true);
|
||||
setCorrect(res.correct);
|
||||
setReason(res.reason);
|
||||
}).catch((e) => console.error(e));
|
||||
}
|
||||
}
|
||||
|
||||
const ResponseSection = (correct: string | null, reason: string | null) => {
|
||||
if (!answered) {
|
||||
return (
|
||||
<div className='w-full h-36'>
|
||||
<div className=' flex-row flex-wrap w-full h-full'>
|
||||
{remainingWords.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="border-gray-300 border-2 m-1 p-1 px-3 rounded-xl inline-block cursor-pointer"
|
||||
onClick={() => handleWordBankClick(word)}>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button className='green' onClick={() => handleSubmit()}>
|
||||
{loading ? <ClipLoader
|
||||
color="white"
|
||||
loading={loading}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
/> : !answered ? "Submit" : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
)
|
||||
} else {
|
||||
return <div className='flex flex-wrap w-full gap-5'>
|
||||
<div className="font-bold">{correct ? "Correct!" : "Incorrect"}</div>
|
||||
<div>{reason}</div>
|
||||
<button className='green w-full' onClick={() => handleSubmit()}>{!answered ? "Submit" : "Continue"}</button>
|
||||
{answered && !correct && <button className='red w-full' onClick={() => handleSubmit()}>I was right</button>}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='v-layout space-y-8 items-center w-full'>
|
||||
<div className='round box h-min no-shadow relative min-h-[60px] flex items-center justify-center mx-5'>
|
||||
{q.question}
|
||||
</div>
|
||||
<div className='flex-row flex-wrap border-b-4 w-full h-36'>
|
||||
{selectedWords.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="border-gray-300 border-b-2 border-2 m-1 p-1 px-3 rounded-xl inline-block cursor-pointer"
|
||||
onClick={() => handleSelectedWordClick(word)}>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{ResponseSection(correct, reason)}
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import {VocabularyQuestion} from "../logic/CourseData";
|
||||
import {Icon} from "@iconify/react";
|
||||
|
||||
interface WrittenVocabularyProps {
|
||||
q: VocabularyQuestion
|
||||
onSubmit: Function;
|
||||
}
|
||||
|
||||
export default function WrittenQuestionExercise({q, onSubmit}: WrittenVocabularyProps) {
|
||||
const [answered, setAnswered] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (answered) {
|
||||
setAnswered(false);
|
||||
onSubmit();
|
||||
} else {
|
||||
setAnswered(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='v-layout gap-5 h-full'>
|
||||
<div className="font-bold">Recall the following word</div>
|
||||
<div className='box text-center'>
|
||||
{q.question}
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center items-center w-full">
|
||||
{answered &&
|
||||
<div className='flex flex-col gap-3 w-full'>
|
||||
<div className="font-bold">{q.pronunciation}</div>
|
||||
<div>{q.description}</div>
|
||||
<div className="text-yellow-600 flex items-center gap-3"><Icon icon="fa:star"/>{q.example}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
{answered ?
|
||||
<>
|
||||
<button className='green my-4' onClick={(e) => handleSubmit()}>I got it!</button>
|
||||
<button className='red' onClick={(e) => handleSubmit()}>I forgot</button>
|
||||
</> :
|
||||
<>
|
||||
<div className="text-gray-400 text-sm mb-5">Please click "show meaning" after you have tried to recall the meaning of the word</div>
|
||||
<button className='white' onClick={(e) => handleSubmit()}>Show Meaning</button>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
+64
-6
@@ -1,6 +1,7 @@
|
||||
@tailwind base
|
||||
@tailwind components
|
||||
@tailwind utilities
|
||||
@yaireo/tagify/src/tagify.scss
|
||||
|
||||
$c-default-text: #222
|
||||
$c-default-icon: #888
|
||||
@@ -10,7 +11,13 @@ $c-green: rgb(88, 204, 2)
|
||||
$c-green-shadow: rgb(88, 167, 0)
|
||||
$c-red: rgb(255, 69, 58)
|
||||
$c-red-shadow: darken($c-red, 10%)
|
||||
$c-gold: rgb(255, 200, 0)
|
||||
$c-gold-flare: rgb(251, 233, 2)
|
||||
$c-gold-shadow: rgb(221, 164, 11)
|
||||
$c-gold-text: rgb(195, 126, 11)
|
||||
$c-white-shadow: rgb(222, 222, 222)
|
||||
$c-gray: rgb(229, 229, 229)
|
||||
$c-gray-shadow: rgb(175, 175, 175)
|
||||
$border-radius: 16px
|
||||
$shadow-width: 0 4px 0
|
||||
|
||||
@@ -78,6 +85,12 @@ button.white
|
||||
&.no-shadow
|
||||
border-width: 2px
|
||||
|
||||
.box.green
|
||||
border: none
|
||||
background-color: $c-green
|
||||
box-shadow: $shadow-width $c-green-shadow
|
||||
color: white
|
||||
|
||||
input
|
||||
@extend .box
|
||||
transition: all 0.2s ease-in-out
|
||||
@@ -101,6 +114,11 @@ h2
|
||||
font-size: 1.5em
|
||||
margin-bottom: 0.5em
|
||||
text-align: center
|
||||
|
||||
.subtext
|
||||
text-align: center
|
||||
color: rgb(75, 85, 99)
|
||||
opacity: 0.8
|
||||
|
||||
.v-layout
|
||||
display: flex
|
||||
@@ -109,13 +127,19 @@ h2
|
||||
|
||||
gap: 1em
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
margin-bottom: 60px
|
||||
|
||||
&.non-center
|
||||
justify-content: flex-start
|
||||
|
||||
&.gap2
|
||||
gap: 2em
|
||||
|
||||
.page-pad
|
||||
padding: 2em 1em
|
||||
height: 100%
|
||||
@extend .non-center
|
||||
|
||||
.navbar
|
||||
position: fixed
|
||||
@@ -168,12 +192,13 @@ h2
|
||||
color: #a0aec0
|
||||
font-size: 1.5rem
|
||||
|
||||
.record-btn
|
||||
.record-container
|
||||
background-color: white !important
|
||||
position: fixed
|
||||
bottom: 20px
|
||||
bottom: 0px
|
||||
left: 50%
|
||||
transform: translateX(-50%)
|
||||
width: calc(100% - 40px)
|
||||
width: 100%
|
||||
padding: 10px 16px
|
||||
|
||||
.record-btn.red
|
||||
@@ -188,7 +213,7 @@ h2
|
||||
margin: 20px auto
|
||||
|
||||
.message
|
||||
margin: 10px
|
||||
margin: 3px
|
||||
padding: 10px
|
||||
border-radius: 5px
|
||||
color: white
|
||||
@@ -201,4 +226,37 @@ h2
|
||||
.message.other
|
||||
align-self: flex-start
|
||||
background-color: $c-green
|
||||
border-radius: 0 20px 20px 20px
|
||||
border-radius: 0 20px 20px 20px
|
||||
|
||||
.tags
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
border: solid $c-white-shadow
|
||||
padding: 10px
|
||||
border-radius: 20px
|
||||
height: 200px
|
||||
overflow-y: scroll
|
||||
align-content: flex-start
|
||||
gap: 10px 5px
|
||||
margin-top: 3px
|
||||
|
||||
.tag
|
||||
background-color: $c-blue
|
||||
color: white
|
||||
border-radius: 20px
|
||||
padding: 5px 10px
|
||||
margin: 0
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
height: 30px
|
||||
|
||||
.delete-tag
|
||||
margin-left: 6px
|
||||
font-weight: 600
|
||||
width: 20px
|
||||
height: 20px
|
||||
color: white
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
+16
-1
@@ -12,6 +12,9 @@ import CollabLearning from './pages/CollabLearning';
|
||||
import Review from './pages/Review';
|
||||
import CharacterSelection from './pages/CharacterSelection';
|
||||
import Character from './pages/Character';
|
||||
import Lesson from './pages/Lesson';
|
||||
import FakeUserSelection from './pages/FakeUserSelection';
|
||||
import UserChat from './pages/UserChat';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -49,7 +52,19 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: '/character',
|
||||
element: <Character/>
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/lesson',
|
||||
element: <Lesson/>
|
||||
},
|
||||
{
|
||||
path: '/fake-user-selection',
|
||||
element: <FakeUserSelection/>
|
||||
},
|
||||
{
|
||||
path: '/user-chat',
|
||||
element: <UserChat/>
|
||||
},
|
||||
])
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
export interface Chapter
|
||||
{
|
||||
name: string
|
||||
steps: Step[]
|
||||
}
|
||||
|
||||
export interface Step
|
||||
{
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
export interface _Question
|
||||
{
|
||||
question: string
|
||||
type: string
|
||||
}
|
||||
export type Question = WrittenQuestion | VocabularyQuestion | VerbalQuestion | VerbalPronunciation | VideoQuestion
|
||||
|
||||
export interface WrittenQuestion extends _Question
|
||||
{
|
||||
wordBank: string[]
|
||||
expected: string
|
||||
type: 'written-question'
|
||||
}
|
||||
|
||||
export interface VocabularyQuestion extends _Question
|
||||
{
|
||||
pronunciation: string
|
||||
description: string
|
||||
example: string
|
||||
type: 'written-vocabulary'
|
||||
}
|
||||
|
||||
export interface VerbalQuestion extends _Question
|
||||
{
|
||||
wordBank: string[]
|
||||
expected: string
|
||||
translation: string
|
||||
url: string
|
||||
type: 'verbal-question'
|
||||
}
|
||||
|
||||
export interface VerbalPronunciation extends _Question
|
||||
{
|
||||
translation: string
|
||||
type: 'verbal-pronunciation'
|
||||
}
|
||||
|
||||
export interface VideoQuestion extends _Question
|
||||
{
|
||||
clipUrl: string
|
||||
description: string
|
||||
expected: string
|
||||
type: 'video'
|
||||
}
|
||||
|
||||
export const chapters_jp: Chapter[] = [
|
||||
{
|
||||
name: 'Order food',
|
||||
steps: [{
|
||||
questions: [
|
||||
{
|
||||
question: 'Translate this sentence: すしをください',
|
||||
wordBank: ['I', 'sushi', 'cookies', 'want', 'please', 'give', 'rice', 'some', 'yesterday'],
|
||||
expected: 'Sushi please / I want sushi',
|
||||
type: "written-question"
|
||||
},
|
||||
{
|
||||
question: '刺身',
|
||||
pronunciation: 'さしみ (sashimi)',
|
||||
description: 'Sashimi is a Japanese delicacy consisting of fresh raw fish or meat sliced into thin pieces and often eaten with soy sauce.',
|
||||
example: '刺身を好きです。 (I like sashimi)',
|
||||
type: 'written-vocabulary',
|
||||
},
|
||||
{
|
||||
question: 'What do you hear?',
|
||||
wordBank: ['刺身', 'ケーキ', 'の', 'は', 'おいしい', 'おもい', 'すし', '中', 'です'],
|
||||
expected: 'ケーキはおいしいです',
|
||||
translation: 'Cake is delicious',
|
||||
url: window.location.origin + '/audio/jp_1_1_3.mp3',
|
||||
type: 'verbal-question',
|
||||
},
|
||||
{
|
||||
question: 'すしをたくさんあります',
|
||||
translation: 'There is a lot of sushi',
|
||||
type: 'verbal-pronunciation',
|
||||
},
|
||||
{
|
||||
question: 'What is the following song about?',
|
||||
clipUrl: window.location.origin + "/video/dango.mp4",
|
||||
description: "This is the song 'Dango Daikazoku' from the anime 'Clannad'. It is about a family of dango.",
|
||||
expected: '団子 or dango',
|
||||
type: 'video',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
// question: "Translate this sentence: Water please",
|
||||
// wordBank: ['水', 'を', 'ください', 'おいしい', 'おもい', 'すし', '中', 'です'],
|
||||
question: 'Translate this sentence: 水をください',
|
||||
wordBank: ['I', 'sushi', 'cookies', 'want', 'please', 'give', 'rice', 'some', 'yesterday'],
|
||||
// expected: '水をください',
|
||||
expected: 'Water please',
|
||||
type: "written-question"
|
||||
},
|
||||
{
|
||||
question: '団子',
|
||||
pronunciation: 'だんご (dango)',
|
||||
description: 'Dango is a kind of mochi, Japanese dumpling and sweet made from mochiko (rice flour).',
|
||||
example: '大きいな団子です。(It is a big dango)',
|
||||
type: 'written-vocabulary',
|
||||
},
|
||||
{
|
||||
question: 'What do you hear?',
|
||||
wordBank: ['刺身', '水', 'は', 'おいしい', 'おもい', 'すし', '中', 'です'],
|
||||
expected: '水です',
|
||||
translation: 'It is water',
|
||||
url: window.location.origin + '/audio/jp_1_2_3.mp3',
|
||||
type: 'verbal-question',
|
||||
},
|
||||
{
|
||||
question: '刺身おください',
|
||||
translation: 'Please give me sashimi',
|
||||
type: 'verbal-pronunciation',
|
||||
},
|
||||
{
|
||||
question: 'What did Yui come to the club meeting for?',
|
||||
clipUrl: window.location.origin + "/video/cake.mp4",
|
||||
description: "This is a clip from the anime 'K-On'. Yui is a member of the light music club. She came to the club meeting to eat cake.",
|
||||
expected: 'ケーキ or cake',
|
||||
type: 'video',
|
||||
},
|
||||
]
|
||||
}]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
export const target_interests = [
|
||||
"gaming", "cooking", "sci-fi", "sports", "music", "programming", "first-person shooters", "painting", "baking", "astronomy", "archery"
|
||||
]
|
||||
|
||||
export const target_names = [
|
||||
"John", "Mary", "Bob", "Alice", "Jane", "Frank", "Sally", "Jack", "Jill", "Joe", "Sue"
|
||||
]
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
interests: string[];
|
||||
}
|
||||
|
||||
export function generateFakeUsers(interests: string[]): User[] {
|
||||
let fakeUsers = [];
|
||||
const randomNumber = Math.floor(Math.random() * target_names.length) + 1;
|
||||
|
||||
for (let i = 0; i < randomNumber; i++) {
|
||||
const randomName = target_names[Math.floor(Math.random() * target_names.length)];
|
||||
const randomNumberOfInterests = Math.floor(Math.random() * target_interests.length) + 1;
|
||||
const randomInterests: string[] = [];
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const randomInterest = target_interests[Math.floor(Math.random() * target_interests.length)];
|
||||
if (!randomInterests.includes(randomInterest)) {
|
||||
randomInterests.push(randomInterest);
|
||||
}
|
||||
}
|
||||
|
||||
// add a random interest from the user's interests
|
||||
const randomInterest = interests[Math.floor(Math.random() * interests.length)];
|
||||
if (!randomInterests.includes(randomInterest)) {
|
||||
randomInterests.push(randomInterest);
|
||||
}
|
||||
|
||||
const newUser = { name: randomName, interests: randomInterests };
|
||||
fakeUsers.push(newUser);
|
||||
}
|
||||
|
||||
return fakeUsers;
|
||||
}
|
||||
+153
-16
@@ -2,23 +2,25 @@
|
||||
import MandarinChinese from '../assets/img/lang/zh.svg'
|
||||
import Japanese from '../assets/img/lang/ja.svg'
|
||||
import English from '../assets/img/lang/en.svg'
|
||||
import {Chapter, chapters_jp} from "./CourseData";
|
||||
|
||||
// db.users: Signup table map<username, password>
|
||||
// db.user: Current logged-in user
|
||||
const db = localStorage
|
||||
|
||||
const backendUrl = 'https://localhost:8000'
|
||||
const backendUrl = 'https://318-bk.hydev.org'
|
||||
|
||||
export interface Lang {
|
||||
name: string
|
||||
code: string
|
||||
icon: string
|
||||
data: Chapter[]
|
||||
}
|
||||
|
||||
export const possibleLangs = [
|
||||
{name: 'Mandarin Chinese', code: 'zh', icon: MandarinChinese},
|
||||
{name: 'Japanese', code: 'ja', icon: Japanese},
|
||||
{name: 'English', code: 'en', icon: English},
|
||||
export const possibleLangs: Lang[] = [
|
||||
// {name: 'Mandarin Chinese', code: 'zh', icon: MandarinChinese, data: []},
|
||||
{name: 'Japanese', code: 'ja', icon: Japanese, data: chapters_jp},
|
||||
// {name: 'English', code: 'en', icon: English, data: []},
|
||||
]
|
||||
|
||||
export function signup(username: string, password: string, language: string)
|
||||
@@ -53,17 +55,54 @@ export function isLoggedIn()
|
||||
return !!db.user
|
||||
}
|
||||
|
||||
export function getLanguage(username: string)
|
||||
{
|
||||
const users = JSON.parse(db.users)
|
||||
return users[username].language
|
||||
}
|
||||
|
||||
export function getUsername()
|
||||
{
|
||||
return db.user
|
||||
}
|
||||
|
||||
export function getXp()
|
||||
{
|
||||
if (!db.completed) return 0
|
||||
const completed = JSON.parse(db.completed)
|
||||
return completed.length * 20
|
||||
}
|
||||
|
||||
export function isStepCompleted(chapter: string, step: number)
|
||||
{
|
||||
if (!db.completed) return false
|
||||
const completed = JSON.parse(db.completed)
|
||||
return completed.includes(`${chapter}-${step}`)
|
||||
}
|
||||
|
||||
export function setStepCompleted(chapter: string, step: number)
|
||||
{
|
||||
if (!db.completed)
|
||||
db.completed = JSON.stringify([])
|
||||
const completed = JSON.parse(db.completed)
|
||||
completed.push(`${chapter}-${step}`)
|
||||
db.completed = JSON.stringify(completed)
|
||||
}
|
||||
|
||||
export function getLanguage(): Lang
|
||||
{
|
||||
const users = JSON.parse(db.users)
|
||||
const lang = users[getUsername()].language
|
||||
if (!lang)
|
||||
{
|
||||
alert('No language set, logging out')
|
||||
logout()
|
||||
window.location.href = '/'
|
||||
}
|
||||
const lang_obj = possibleLangs.find(l => l.name === lang)!
|
||||
if (!lang_obj)
|
||||
{
|
||||
alert(`Invalid language set: ${lang}, logging out`)
|
||||
logout()
|
||||
window.location.href = '/'
|
||||
}
|
||||
return lang_obj
|
||||
}
|
||||
|
||||
export interface CharacterChatCreationRequest
|
||||
{
|
||||
character: string;
|
||||
@@ -71,19 +110,19 @@ export interface CharacterChatCreationRequest
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface CharacterChatCreationResponse
|
||||
export interface ChatCreationResponse
|
||||
{
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
export async function startFictionalChat(character: string): Promise<CharacterChatCreationResponse> {
|
||||
export async function startFictionalChat(character: string): Promise<ChatCreationResponse> {
|
||||
const currUser = getUsername();
|
||||
const language = getLanguage(currUser);
|
||||
const language = getLanguage();
|
||||
|
||||
const request: CharacterChatCreationRequest = {
|
||||
character: character,
|
||||
user_name: currUser,
|
||||
language: language
|
||||
language: language.name
|
||||
};
|
||||
|
||||
const response = await fetch(`${backendUrl}/character-chat`, {
|
||||
@@ -103,6 +142,42 @@ export async function startFictionalChat(character: string): Promise<CharacterCh
|
||||
return json.session_id;
|
||||
}
|
||||
|
||||
export interface HumanChatCreationRequest
|
||||
{
|
||||
user_name: string;
|
||||
user_hobbies: string[];
|
||||
target_name: string;
|
||||
target_hobbies: string[];
|
||||
language: string;
|
||||
}
|
||||
|
||||
export async function startHumanChat(user_hobbies: string[], target_name: string, target_hobbies: string[]): Promise<ChatCreationResponse> {
|
||||
|
||||
const request: HumanChatCreationRequest = {
|
||||
user_name: getUsername(),
|
||||
user_hobbies: user_hobbies,
|
||||
target_name: target_name,
|
||||
target_hobbies: target_hobbies,
|
||||
language: getLanguage().name
|
||||
};
|
||||
|
||||
const response = await fetch(`${backendUrl}/human-chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.session_id;
|
||||
}
|
||||
|
||||
export async function speechToText(audioBlob: Blob): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('audio_file', audioBlob, 'audio.wav');
|
||||
@@ -142,6 +217,27 @@ export async function characterChatMessage(sessionId: string, message: string):
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function humanChatMessage(sessionId: string, message: string): Promise<{ msg: string, audio_id: string }> {
|
||||
|
||||
const request = {msg : message};
|
||||
|
||||
const response = await fetch(`${backendUrl}/human-chat/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function getAudio(audioId: string): Promise<Blob> {
|
||||
const response = await fetch(`${backendUrl}/audio/${audioId}`);
|
||||
|
||||
@@ -151,4 +247,45 @@ export async function getAudio(audioId: string): Promise<Blob> {
|
||||
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserQuestionRequest
|
||||
{
|
||||
question: string;
|
||||
user_answer: string;
|
||||
expected: string;
|
||||
chapter: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface UserQuestionFeedbackResponse
|
||||
{
|
||||
correct: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function getAIMarking(question: string, user_answer: string, expected: string, chapter: string, language: string): Promise<UserQuestionFeedbackResponse> {
|
||||
const request: UserQuestionRequest = {
|
||||
question: question,
|
||||
user_answer: user_answer,
|
||||
expected: expected,
|
||||
chapter: chapter,
|
||||
language: language
|
||||
};
|
||||
|
||||
const response = await fetch(`${backendUrl}/ai-mark`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.message}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/react';
|
||||
import CharacterBadge from '../components/CharacterBadge';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { speechToText, characterChatMessage, getAudio } from '../logic/sdk';
|
||||
import { SyncLoader } from 'react-spinners';
|
||||
|
||||
export default function Character() {
|
||||
const location = useLocation();
|
||||
@@ -12,10 +13,22 @@ export default function Character() {
|
||||
type Message = { text: string, sender: string };
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOtherLoading, setIsOtherLoading] = useState(false);
|
||||
|
||||
let chunks = [] as any;
|
||||
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
||||
|
||||
const messageEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messageEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
||||
mediaRecorder.current = new MediaRecorder(stream);
|
||||
@@ -25,12 +38,17 @@ export default function Character() {
|
||||
|
||||
mediaRecorder.current.onstop = async (e) => {
|
||||
setIsRecording(false);
|
||||
setIsLoading(true);
|
||||
const blob = new Blob(chunks, { type: 'audio/wav' });
|
||||
chunks = [];
|
||||
|
||||
const audioFile = new File([blob], "audio.wav", { type: 'audio/wav' });
|
||||
|
||||
const text = await speechToText(audioFile);
|
||||
setMessages(prevMessages => [...prevMessages, { text: text, sender: 'me' }]);
|
||||
setIsLoading(false);
|
||||
|
||||
setIsOtherLoading(true);
|
||||
const response = await characterChatMessage(sessionId, text);
|
||||
const { msg, audio_id } = response;
|
||||
const audioBlob = await getAudio(audio_id);
|
||||
@@ -38,7 +56,8 @@ export default function Character() {
|
||||
const audioUrl = URL.createObjectURL(audioFileResponse);
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.play();
|
||||
setMessages(prevMessages => [...prevMessages, { text: text, sender: 'me' }, { text: msg, sender: 'other' }]);
|
||||
setMessages(prevMessages => [...prevMessages, { text: msg, sender: 'other' }]);
|
||||
setIsOtherLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
@@ -57,25 +76,52 @@ export default function Character() {
|
||||
}, [isRecording]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="v-layout p-10">
|
||||
<Icon icon="mdi:arrow-left" className="back-button" onClick={() => navigate(-1)} />
|
||||
<h1 className="text-center">Talk With...</h1>
|
||||
<CharacterBadge name={name} image={image} onClick={() => {}}/>
|
||||
<div className="chat-area">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-center text-gray-400">Please record a message to start the conversation.</p>
|
||||
) : (
|
||||
messages.map((message, index) => (
|
||||
<div key={index} className={`message ${message.sender}`}>
|
||||
<p>{message.text}</p>
|
||||
<div className='flex flex-col h-screen'>
|
||||
<div className="flex-grow overflow-y-auto p-6">
|
||||
<div>
|
||||
<Icon icon="mdi:arrow-left" className="back-button" onClick={() => navigate(-1)} />
|
||||
<h1 className="text-center">Talk With...</h1>
|
||||
<CharacterBadge name={name} image={image} onClick={() => {}}/>
|
||||
<div className="chat-area pb-10">
|
||||
{messages.length === 0 && !isLoading ? (
|
||||
<p className="text-center text-gray-400">Please record a message to start the conversation.</p>
|
||||
) : (
|
||||
messages.map((message, index) => (
|
||||
<div key={index} className={`message ${message.sender}`}>
|
||||
<p>{message.text}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="message me">
|
||||
<SyncLoader
|
||||
color="white"
|
||||
loading={isLoading}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
size={10}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
)}
|
||||
{isOtherLoading && (
|
||||
<div className="message other">
|
||||
<SyncLoader
|
||||
color="white"
|
||||
loading={isOtherLoading}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
size={10}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={messageEndRef} />
|
||||
</div>
|
||||
<div className='record-container'>
|
||||
<button className={`${isRecording ? 'red' : ''}`} onClick={handleRecord}>
|
||||
{isRecording ? 'Stop Recording' : 'Record'}
|
||||
</button>
|
||||
</div>
|
||||
<button className={`record-btn ${isRecording ? 'red' : ''}`} onClick={handleRecord}>
|
||||
{isRecording ? 'Stop Recording' : 'Record'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function CharacterSelection() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="v-layout p-10">
|
||||
<div className="v-layout p-6">
|
||||
<h1>Talk With...</h1>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{characters.map(character => (
|
||||
|
||||
@@ -1,14 +1,72 @@
|
||||
import NavBar from "../components/NavBar"
|
||||
import React, { useState } from 'react';
|
||||
import { generateFakeUsers } from '../logic/fakeUsers';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {getLanguage} from "../logic/sdk";
|
||||
|
||||
export default function CollabLearning() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [interests, setInterests] = useState<string[]>([]);
|
||||
const [newInterest, setNewInterest] = useState("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
const handleDelete = (tagToDelete: any) => {
|
||||
setInterests(interests.filter(tag => tag !== tagToDelete));
|
||||
}
|
||||
|
||||
const handleAddTag = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (newInterest && !interests.includes(newInterest)) {
|
||||
setInterests([...interests, newInterest]);
|
||||
setNewInterest('');
|
||||
setErrorMessage('');
|
||||
} else if (interests.includes(newInterest)) {
|
||||
setErrorMessage('You already entered this interest');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMatchClick = () => {
|
||||
if (interests.length === 0) {
|
||||
setErrorMessage('Please enter at least one interest');
|
||||
return;
|
||||
}
|
||||
|
||||
const fakeUsers = generateFakeUsers(interests)
|
||||
|
||||
navigate('/fake-user-selection', { state: { fakeUsers: fakeUsers, interests: interests } });
|
||||
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="v-layout p-10">
|
||||
<div className="v-layout p-6">
|
||||
<div className="flex flex-col flex-1">
|
||||
<h1>Collaborative Learning</h1>
|
||||
<h1>Chat</h1>
|
||||
<p className="subtext">Find people fluent in {getLanguage().name} to Chat!</p>
|
||||
<p className="subtext">Help them learn a language you know!</p>
|
||||
<p className="font-bold pt-10">Interests</p>
|
||||
<div className="tags">
|
||||
{interests.length === 0 ? (
|
||||
<p className="subtext">Enter a new interest below and press "Enter"!</p>
|
||||
) : (
|
||||
interests.map((interest) => (
|
||||
<div key={interest} className="tag">
|
||||
{interest}
|
||||
<span className="delete-tag" onClick={(e) => { e.stopPropagation(); handleDelete(interest); }}> X</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{errorMessage && <p className="text-red-600 font-bold mt-5">{errorMessage}</p>}
|
||||
<input className="mt-5" type="text" value={newInterest} onChange={(e) => setNewInterest(e.target.value)} onKeyDown={handleAddTag} placeholder="Enter your interest here!" />
|
||||
<button className="mt-5 green" onClick={() => handleMatchClick()}>Find Chat Partners!</button>
|
||||
</div>
|
||||
<NavBar />
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@import "../index"
|
||||
|
||||
.course-button
|
||||
@extend button
|
||||
@extend .green
|
||||
|
||||
box-shadow: 0 8px 0 $c-green-shadow
|
||||
|
||||
width: 70px
|
||||
height: 60px
|
||||
border-radius: 50%
|
||||
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
font-size: 5em
|
||||
overflow: hidden
|
||||
|
||||
&.gray
|
||||
background: $c-gray
|
||||
color: $c-gray-shadow
|
||||
box-shadow: 0 8px 0 $c-gray-shadow
|
||||
|
||||
&.gold
|
||||
background: $c-gold
|
||||
color: $c-gold-text
|
||||
box-shadow: 0 8px 0 $c-gold-shadow
|
||||
|
||||
|
||||
@@ -1,14 +1,75 @@
|
||||
import NavBar from "../components/NavBar"
|
||||
import {Icon} from "@iconify/react";
|
||||
import {getLanguage, isLoggedIn, isStepCompleted} from "../logic/sdk";
|
||||
import React from "react";
|
||||
import "./Course.sass"
|
||||
import {Step} from "../logic/CourseData";
|
||||
import {useLocation, useNavigate} from "react-router-dom";
|
||||
|
||||
export default function Course() {
|
||||
function CourseButton(props: {state: 'active' | 'locked' | 'completed', index: number})
|
||||
{
|
||||
let cs = 'course-button ' + {active: 'green', locked: 'gray', completed: 'gold'}[props.state];
|
||||
|
||||
return (
|
||||
<div className="v-layout p-10">
|
||||
<div className="flex flex-col flex-1">
|
||||
<h1>Course Page</h1>
|
||||
</div>
|
||||
<NavBar />
|
||||
const icon = {active: 'solar:star-bold', locked: 'solar:lock-bold', completed: 'mingcute:check-fill'}[props.state];
|
||||
|
||||
// Parameters for the sin wave
|
||||
const amplitude = 50; // Change this value to adjust the wave's height
|
||||
const frequency = 1; // Change this value to adjust the wave's frequency
|
||||
|
||||
// Calculate the horizontal translation
|
||||
const translateXValue = amplitude * Math.sin(props.index * frequency);
|
||||
|
||||
return <div className={cs} style={{transform: `translateX(${translateXValue}px)`}}>
|
||||
<Icon icon={icon}/>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
export default function Course()
|
||||
{
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isLoggedIn())
|
||||
{
|
||||
window.location.href = '/login';
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
// Get language
|
||||
const lang = getLanguage();
|
||||
|
||||
function click(step: Step)
|
||||
{
|
||||
navigate('/lesson', {state: {questions: step.questions, home: location.pathname}})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="v-layout page-pad non-center">
|
||||
<div className="flex items-center justify-between font-bold">
|
||||
<img src={lang.icon} alt={lang.name} className="max-w-[50px]"/>
|
||||
<div className="flex items-center text-red-500 text-lg gap-2">
|
||||
<Icon icon="bi:fire" className="text-xl"/>
|
||||
<span>1</span>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
|
||||
}
|
||||
{lang.data.map((chapter, i) => <div key={i}>
|
||||
<div className="box green mb-10">
|
||||
<div>Chapter 1, Section 1</div>
|
||||
<div className="font-bold">{chapter.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="v-layout non-center items-center gap2">
|
||||
{chapter.steps.map((step, i) => <div key={i} onClick={() => click(step)}>
|
||||
<CourseButton state={isStepCompleted(chapter.name, i) ? 'completed' : i == 0 || isStepCompleted(chapter.name, i - 1) ? 'active' : 'locked'}
|
||||
index={i}/></div>)}
|
||||
{[...Array(5)].map((_, i) => <CourseButton state={'locked'} index={i + chapter.steps.length} key={i}/>)}
|
||||
</div>
|
||||
|
||||
<NavBar/>
|
||||
</div>)}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@import "../index"
|
||||
|
||||
.user-card
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
margin: 10px
|
||||
border: solid $c-green
|
||||
border-radius: 20px
|
||||
padding: 10px
|
||||
|
||||
|
||||
.user-interests
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex-wrap: wrap
|
||||
gap: 5px
|
||||
padding: 5px
|
||||
|
||||
.user-interest
|
||||
background-color: $c-green
|
||||
color: white
|
||||
border-radius: 20px
|
||||
padding: 5px 10px
|
||||
margin: 0
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
height: 30px
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom"
|
||||
import { Icon } from '@iconify/react';
|
||||
import "./FakeUserSelection.sass";
|
||||
import { startHumanChat } from "../logic/sdk";
|
||||
|
||||
export default function FakeUserSelection() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { fakeUsers, interests } = location.state;
|
||||
|
||||
const handleUserClick = (user: any) => {
|
||||
startHumanChat(interests, user.name, user.interests).then((sessionId) => {
|
||||
console.log(sessionId);
|
||||
navigate('/user-chat', { state: { user: user, sessionId: sessionId } });
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="v-layout p-6">
|
||||
<Icon icon="mdi:arrow-left" className="back-button" onClick={() => navigate(-1)} />
|
||||
<div className="flex flex-col flex-1">
|
||||
<h1>Learning Partners</h1>
|
||||
</div>
|
||||
{fakeUsers.map((user: any, index: any) => (
|
||||
<div className="user-card" key={index} onClick={
|
||||
() => handleUserClick(user)
|
||||
}>
|
||||
<div>
|
||||
<div className="w-20 h-20 mx-auto mb-2 p-2 rounded-full border-2 border-dashed border-gray-300 relative">
|
||||
<span className="text-2xl uppercase font-semibold text-gray-400 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">{user.name[0]}</span>
|
||||
</div>
|
||||
<h2>{user.name}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-bold ml-2">Interests</p>
|
||||
<div className="user-interests">
|
||||
{user.interests.map((interest: any, i: any) => (
|
||||
<span className="user-interest" key={i}>{interest}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import React, {useState} from 'react';
|
||||
import WrittenQuestionExercise from "../components/WrittenQuestionExercise"
|
||||
import WrittenVocabularyExercise from "../components/WrittenVocabularyExercise"
|
||||
import VerbalQuestionsExercise from "../components/VerbalQuestionsExercise"
|
||||
import Progress from '../components/Progress';
|
||||
import VerbalPronunciationExercise from '../components/VerbalPronunciationExercise';
|
||||
import LessonComplete from '../components/LessonComplete';
|
||||
import {_Question, chapters_jp, Question} from "../logic/CourseData";
|
||||
import VideoExercise from "../components/VideoExercise";
|
||||
|
||||
export default function Lesson()
|
||||
{
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const {questions, home} = location.state;
|
||||
const [currQuestion, setCurrQuestion] = useState<number>(0);
|
||||
|
||||
console.log(questions)
|
||||
|
||||
const handleNavigateBack = () =>
|
||||
{
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const onSubmit = () =>
|
||||
{
|
||||
setCurrQuestion(currQuestion + 1);
|
||||
}
|
||||
|
||||
const renderQuestion = (currIndex: number) =>
|
||||
{
|
||||
if (currIndex >= questions.length) {
|
||||
return <LessonComplete home={home}/>;
|
||||
}
|
||||
const chapter = 'Ordering food';
|
||||
const question: Question = questions[currQuestion];
|
||||
switch (question.type)
|
||||
{
|
||||
case 'written-question':
|
||||
return <WrittenQuestionExercise key={currQuestion} q={question} chapter={chapter} onSubmit={onSubmit}/>;
|
||||
case 'written-vocabulary':
|
||||
return <WrittenVocabularyExercise key={currQuestion} q={question} onSubmit={onSubmit}/>;
|
||||
case 'verbal-question':
|
||||
return <VerbalQuestionsExercise key={currQuestion} q={question} chapter={chapter} onSubmit={onSubmit}/>;
|
||||
case 'verbal-pronunciation':
|
||||
return <VerbalPronunciationExercise key={currQuestion} q={question} chapter={chapter} onSubmit={onSubmit}/>;
|
||||
case 'video':
|
||||
return <VideoExercise key={currQuestion} q={question} chapter={chapter} onSubmit={onSubmit}/>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="v-layout page-pad non-center">
|
||||
<Progress percent={currQuestion / questions.length * 100} back={handleNavigateBack}/>
|
||||
<div className="p-5 h-full">
|
||||
{renderQuestion(currQuestion)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { login } from '../logic/sdk';
|
||||
import {isLoggedIn, login} from '../logic/sdk';
|
||||
|
||||
export default function Login() {
|
||||
|
||||
@@ -9,6 +9,12 @@ export default function Login() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn()) {
|
||||
navigate("/courses");
|
||||
}
|
||||
}, []);
|
||||
|
||||
function submitLogin() {
|
||||
try {
|
||||
login(username, password)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@import "../index"
|
||||
|
||||
.profile-stats > div
|
||||
@extend .box
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 10px
|
||||
|
||||
*
|
||||
white-space: nowrap
|
||||
|
||||
svg
|
||||
font-size: 2em
|
||||
max-width: 25px
|
||||
|
||||
> div
|
||||
> div:nth-child(2)
|
||||
font-size: 0.9em
|
||||
|
||||
|
||||
@@ -1,28 +1,91 @@
|
||||
import NavBar from "../components/NavBar"
|
||||
import { getUsername, getLanguage, logout } from "../logic/sdk"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {getUsername, getLanguage, logout} from "../logic/sdk"
|
||||
import {useNavigate} from "react-router-dom"
|
||||
import {Icon} from "@iconify/react";
|
||||
import React from "react";
|
||||
import "./Profile.sass"
|
||||
|
||||
export default function Profile() {
|
||||
export default function Profile()
|
||||
{
|
||||
|
||||
const username = getUsername();
|
||||
const navigate = useNavigate();
|
||||
const username = getUsername();
|
||||
const lang = getLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleLougout() {
|
||||
logout();
|
||||
console.log("Logged out");
|
||||
navigate('/')
|
||||
}
|
||||
function handleLougout()
|
||||
{
|
||||
logout();
|
||||
console.log("Logged out");
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="v-layout p-10">
|
||||
<div className="flex flex-col flex-1 h-full">
|
||||
<h1>Profile</h1>
|
||||
<h2>Username: {username}</h2>
|
||||
<h2>Currently Learning: {getLanguage(username)}</h2>
|
||||
<button className="red mt-auto mb-12" onClick={() => handleLougout()}>Logout</button>
|
||||
</div>
|
||||
<NavBar />
|
||||
return (
|
||||
<div className="v-layout page-pad non-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-6">
|
||||
<div className="w-20 h-20 mx-auto mb-2 p-2 rounded-full border-2 border-dashed border-gray-300 relative">
|
||||
<span className="text-2xl uppercase font-semibold text-gray-400 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">{username[0]}</span>
|
||||
</div>
|
||||
<h2 className="text-4xl font-semibold">{username}</h2>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
<div className="flex flex-col mb-4 text-gray-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="fa-solid:clock" />
|
||||
<span>Joined November 2023</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="fa-solid:user-friends" />
|
||||
<span>0 Friends</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="fa-solid:language" />
|
||||
<span>{getLanguage().name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mb-4"/>
|
||||
<h3 className="text-xl font-semibold mb-4">
|
||||
Statistics
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 profile-stats">
|
||||
<div>
|
||||
<Icon icon="fa-solid:fire" className="text-red-400"/>
|
||||
<div>
|
||||
<div className="font-bold">0</div>
|
||||
<div>Day streak</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Icon icon="ph:lightning-fill" className="text-yellow-400"/>
|
||||
<div>
|
||||
<div className="font-bold">10</div>
|
||||
<div>Total XP</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Icon icon="fa-solid:check" className="text-green-400"/>
|
||||
<div>
|
||||
<div className="font-bold">0</div>
|
||||
<div>Courses</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Icon icon="mdi:microphone-message" className="text-blue-400"/>
|
||||
<div>
|
||||
<div className="font-bold">0</div>
|
||||
<div>Speaking</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavBar/>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import NavBar from "../components/NavBar"
|
||||
import { getLanguage, getUsername } from "../logic/sdk";
|
||||
import {_Question, WrittenQuestion} from "../logic/CourseData";
|
||||
|
||||
export default function Review() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const writtenReview = ["Question", "Vocabulary"];
|
||||
const verbalReview = ["Question", "Pronunciation"];
|
||||
|
||||
const handleReviewLessonClick = (reviewType: string, lesson: string) => {
|
||||
const lessons: _Question[] = getLanguage().data.flatMap(chapter => chapter.steps).flatMap(step => step.questions);
|
||||
navigate('/lesson', { state: { questions: lessons.filter(it => it.type == `${reviewType}-${lesson}`), home: location.pathname } });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout-v p-10">
|
||||
<div className="flex flex-col flex-1">
|
||||
<h1>Review Page</h1>
|
||||
<div className="layout-v page-pad">
|
||||
<h1>Daily Review</h1>
|
||||
<h2>Written</h2>
|
||||
<div className="flex flex-col flex-1 mb-8 gap-3">
|
||||
{writtenReview.map(lesson => (
|
||||
<button className="white" key={lesson}
|
||||
onClick={() => handleReviewLessonClick("written", lesson.toLowerCase())}>
|
||||
{lesson}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<h2>Verbal/Listening</h2>
|
||||
<div className="flex flex-col flex-1 gap-3">
|
||||
{verbalReview.map(lesson => (
|
||||
<button className="white" key={lesson}
|
||||
onClick={() => handleReviewLessonClick("verbal", lesson.toLowerCase())}>
|
||||
{lesson}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<NavBar />
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {possibleLangs, signup} from '../logic/sdk';
|
||||
import {isLoggedIn, possibleLangs, signup} from '../logic/sdk';
|
||||
import Progress from "../components/Progress";
|
||||
import DuoWriting from "../assets/img/duo-writing.png";
|
||||
import ChatBox from "../components/ChatBox";
|
||||
@@ -21,6 +21,12 @@ export default function Signup() {
|
||||
_setStage(stage)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn()) {
|
||||
navigate("/courses");
|
||||
}
|
||||
}, []);
|
||||
|
||||
function submitSignup() {
|
||||
try {
|
||||
if (password !== confirmPassword) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { humanChatMessage } from '../logic/sdk';
|
||||
import { SyncLoader } from 'react-spinners';
|
||||
|
||||
export default function Character() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, sessionId } = location.state;
|
||||
|
||||
type Message = { text: string, sender: string };
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isOtherLoading, setIsOtherLoading] = useState(false);
|
||||
|
||||
const messageEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messageEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages]);
|
||||
|
||||
const handleSendClick = async () => {
|
||||
if (message !== '') {
|
||||
setMessages(prevMessages => [...prevMessages, { text: message, sender: 'me' }]);
|
||||
setMessage('');
|
||||
|
||||
setIsOtherLoading(true);
|
||||
const response = await humanChatMessage(sessionId, message);
|
||||
const { msg } = response;
|
||||
setMessages(prevMessages => [...prevMessages, { text: msg, sender: 'other' }]);
|
||||
setIsOtherLoading(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-grow overflow-y-auto p-6">
|
||||
<div className="top-part">
|
||||
<Icon icon="mdi:arrow-left" className="back-button" onClick={() => navigate(-1)} />
|
||||
<div>
|
||||
<div className="w-40 h-40 mx-auto mb-2 p-4 rounded-full border-2 border-dashed border-gray-300 relative">
|
||||
<span className="text-4xl uppercase font-semibold text-gray-400 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">{user.name[0]}</span>
|
||||
</div>
|
||||
<h2 className='font-bold'>{user.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-area">
|
||||
{messages.length === 0 ? (
|
||||
<p className='subtext'>Say hi to your chat partner and introduce yourself!</p>
|
||||
) : (
|
||||
messages.map((message, index) => (
|
||||
<div key={index} className={`message ${message.sender}`}>
|
||||
<p>{message.text}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isOtherLoading && (
|
||||
<div className="message other">
|
||||
<SyncLoader
|
||||
color="white"
|
||||
loading={isOtherLoading}
|
||||
aria-label="Loading Spinner"
|
||||
data-testid="loader"
|
||||
size={10}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messageEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between p-3 bg-white">
|
||||
<input
|
||||
className='flex-1 mr-3'
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder='Type a message...'
|
||||
/>
|
||||
<button className="w-auto" onClick={handleSendClick}>
|
||||
<Icon icon="mdi:send" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import DuoSplash from "../assets/img/duo-splash.png";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {isLoggedIn} from "../logic/sdk";
|
||||
import {useEffect} from "react";
|
||||
|
||||
export default function Welcome()
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn()) {
|
||||
navigate("/courses");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <div className="flex flex-col h-screen justify-center">
|
||||
<div className="flex flex-col p-5 gap-5 items-center">
|
||||
<img src={DuoSplash} alt="Duolingo Logo"></img>
|
||||
|
||||
@@ -7905,6 +7905,11 @@ react-scripts@^5.0.1:
|
||||
optionalDependencies:
|
||||
fsevents "^2.3.2"
|
||||
|
||||
react-spinners@^0.13.8:
|
||||
version "0.13.8"
|
||||
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.13.8.tgz#5262571be0f745d86bbd49a1e6b49f9f9cb19acc"
|
||||
integrity sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==
|
||||
|
||||
react@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
|
||||
Reference in New Issue
Block a user