47 Commits

Author SHA1 Message Date
azalea 4076776d7d Merge branch 'master' of https://github.com/hykilpikonna/CSC318
Deploy to GH Pages / build-and-deploy (push) Has been cancelled
2023-12-06 06:47:13 -05:00
azalea 2da4bb213e [+] Finalize 2023-12-06 06:46:40 -05:00
Azalea 966c6a271b Merge pull request #9 from hykilpikonna/message-loading
[+] Message loading indicator
2023-12-06 13:59:13 +09:00
Leuxll 354cb6f322 Delete frontend/package-lock.json 2023-12-05 18:40:58 -05:00
Yue Fung Lee eb54b0470d Added message loading indicator 2023-12-05 18:29:08 -05:00
Juan Pablo Acosta a5ecfde9db Merge pull request #8 from hykilpikonna/page-improvements
Page improvements
2023-12-02 07:00:34 -05:00
juanpabloacosta 61fa2495e4 Generalized completion message. 2023-12-02 06:58:13 -05:00
juanpabloacosta 9f65f9e935 Added lesson complete page after completing a module. 2023-12-02 06:55:43 -05:00
juanpabloacosta 82460da4e5 Fixed re-rendering of question components and context to vocabulary question. 2023-12-02 06:36:11 -05:00
azalea 1574fcce0c [+] Complete course 2023-12-01 08:31:15 -05:00
azalea 2145b08e16 [+] Prononciations 2023-12-01 07:42:43 -05:00
azalea 1397e50912 [F] Wrong course, F 2023-12-01 05:39:13 -05:00
azalea e49e9b2c8e Merge branch 'master' of https://github.com/hykilpikonna/CSC318 2023-12-01 05:35:59 -05:00
azalea 7935b78ce4 [O] Use course data 2023-12-01 05:35:55 -05:00
azalea b2f0746ec4 [+] Auto redirect 2023-12-01 05:22:05 -05:00
azalea 28eae67f9e [O] Cleanup 2023-12-01 05:21:52 -05:00
azalea 177139f9f5 [+] Course data 2023-12-01 05:21:23 -05:00
azalea d7dee43d81 [+] Deployed backend 2023-12-01 05:20:53 -05:00
Azalea 3f5751f59f [F] Fix build 2023-12-01 00:34:50 -08:00
Azalea 83b89ea942 Merge pull request #7 from hykilpikonna/collab-learning 2023-12-01 17:28:40 +09:00
Leuxll f3cbca4b73 Merge branch 'master' into collab-learning 2023-12-01 00:49:46 -05:00
Yue Fung Lee f89cf12ab3 Added Chatting between users 2023-12-01 00:46:24 -05:00
Yue Fung Lee 514fba236c fixed layout and autoscroll 2023-12-01 00:45:36 -05:00
Azalea 5c6fc7a123 Merge pull request #6 from hykilpikonna/review-lesson-page 2023-12-01 13:45:34 +09:00
juanpabloacosta 43fa157687 Laid out the verbal question exercise, but incomplete (needs text to speech). 2023-11-30 23:33:35 -05:00
juanpabloacosta f0efdb556f Fully implemented the verbal pronunciation exercise. 2023-11-30 23:33:11 -05:00
juanpabloacosta b88b0fd1f4 Added paths to verbal questions. 2023-11-30 23:32:57 -05:00
juanpabloacosta 4c293e463e Added ability to remember where you started lesson (ie. course or review) 2023-11-30 20:11:45 -05:00
juanpabloacosta 9c7f8576b8 Fixed styling and appearance of questions. 2023-11-30 19:59:47 -05:00
juanpabloacosta 56d51ca32f Included rendering of WrittenVocabularyExercise in lesson. 2023-11-30 19:42:24 -05:00
juanpabloacosta 6dcab68366 Created WrittenVocabulary exercise question. 2023-11-30 19:42:06 -05:00
juanpabloacosta 3963f9c37a Added vocabular question path. 2023-11-30 19:41:32 -05:00
Yue Fung Lee 45d9e4bcb7 Added Creating Random Users 2023-11-30 19:05:15 -05:00
Yue Fung Lee 85fe1b807f fixed review styles 2023-11-30 19:00:45 -05:00
juanpabloacosta f3588e4831 Added spinner and functional marking of user's response. 2023-11-30 17:58:56 -05:00
juanpabloacosta 72db21192f Added progress bar and forced re-rendering of question component. 2023-11-30 17:58:31 -05:00
juanpabloacosta f737889799 Added written questions for mandarin. 2023-11-30 17:58:03 -05:00
juanpabloacosta 492589fe49 Added package for spinners. 2023-11-30 17:57:32 -05:00
azalea a5a8b13a6b [+] Profile page 2023-11-30 17:30:00 -05:00
azalea 6a21716ec6 [+] Course page 2023-11-30 17:03:39 -05:00
azalea 8f94bdf73a [S] styling fixes 2023-11-30 16:11:19 -05:00
Azalea d89c91b643 Merge pull request #5 from jpablo2002/review-lessons-page 2023-12-01 02:43:18 +09:00
juanpabloacosta 2eab4e97af Wrote component for a WrittenQuestion type exercise. 2023-11-30 07:22:13 -05:00
juanpabloacosta 4e8a4acaae Wrote lesson page (currently only one type of question). 2023-11-30 07:21:52 -05:00
juanpabloacosta a34671434b Wrote page for Review with one type of review and questions for 1 language. 2023-11-30 07:21:09 -05:00
juanpabloacosta 498d188925 Added interfaces and helper function to send request for AI marking. 2023-11-30 07:20:37 -05:00
juanpabloacosta 1ff4c1cf1b Added path to a lesson (either from Review or Course since similar). 2023-11-30 07:05:06 -05:00
34 changed files with 1514 additions and 88 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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.
BIN
View File
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>
}
+3 -3
View File
@@ -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>
)}
+71
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+138
View File
@@ -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',
},
]
}]
}
]
+40
View File
@@ -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
View File
@@ -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;
}
+64 -18
View File
@@ -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>
)
+1 -1
View File
@@ -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 => (
+61 -3
View File
@@ -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>
)
}
}
+29
View File
@@ -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
+70 -9
View File
@@ -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>
)
}
+29
View File
@@ -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
+47
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+8 -2
View File
@@ -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)
+21
View File
@@ -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
+84 -21
View File
@@ -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>
)
}
+33 -5
View File
@@ -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>
)
}
}
+8 -2
View File
@@ -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) {
+91
View File
@@ -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>
)
}
+8
View File
@@ -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>
+5
View File
@@ -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"