diff --git a/src/components/ExamEditor/ExercisePicker/index.tsx b/src/components/ExamEditor/ExercisePicker/index.tsx index 81f69128..48f7cde2 100644 --- a/src/components/ExamEditor/ExercisePicker/index.tsx +++ b/src/components/ExamEditor/ExercisePicker/index.tsx @@ -9,7 +9,6 @@ import { Module } from "@/interfaces"; import useExamEditorStore from "@/stores/examEditor"; import { LevelPart, ListeningPart, Message, ReadingPart } from "@/interfaces/exam"; import { BsArrowRepeat } from "react-icons/bs"; -import { writingTask } from "@/stores/examEditor/sections"; interface ExercisePickerProps { module: string; diff --git a/src/components/ExamEditor/Exercises/Script/index.tsx b/src/components/ExamEditor/Exercises/Script/index.tsx index d1a42d19..d0cc56b8 100644 --- a/src/components/ExamEditor/Exercises/Script/index.tsx +++ b/src/components/ExamEditor/Exercises/Script/index.tsx @@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/card'; import Input from '@/components/Low/Input'; import { FaFemale, FaMale, FaPlus } from 'react-icons/fa'; import clsx from 'clsx'; +import { toast } from 'react-toastify'; export interface Speaker { id: number; @@ -65,7 +66,7 @@ const ScriptEditor: React.FC = ({ section, editing = false, local, setLoc position: index % 2 === 0 ? 'left' : 'right' })); } - + const existingScript = local as ScriptLine[]; const existingSpeakers = new Set(); const speakerGenders = new Map(); @@ -217,6 +218,12 @@ const ScriptEditor: React.FC = ({ section, editing = false, local, setLoc }, [local]); if (!isConversation) { + if (typeof local !== 'string') { + toast.error(`Section ${section} is monologue based, but the import contained a conversation!`); + setLocal(''); + return null; + } + return ( @@ -238,6 +245,12 @@ const ScriptEditor: React.FC = ({ section, editing = false, local, setLoc ); } + if (typeof local === 'string') { + toast.error(`Section ${section} is conversation based, but the import contained a monologue!`); + setLocal([]); + return null; + } + return ( diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/index.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/index.tsx index 76a1d54a..43fa9b3a 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/index.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/index.tsx @@ -16,11 +16,10 @@ interface Props { onDiscard: () => void; onEdit?: () => void; module: Module; - listeningSection?: number; context: Generating; } -const SectionContext: React.FC = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module, context, listeningSection }) => { +const SectionContext: React.FC = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module, context }) => { const { currentModule } = useExamEditorStore(); const { generating, levelGenerating } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! @@ -54,7 +53,7 @@ const SectionContext: React.FC = ({ sectionId, title, description, render {loading ? ( ) : ( - renderContent(editing, listeningSection) + renderContent(editing) )} diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx index 639d67e4..c4ba59fa 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { LevelPart, ListeningPart } from "@/interfaces/exam"; +import { useCallback, useEffect, useState } from "react"; +import { LevelPart, ListeningPart, Script } from "@/interfaces/exam"; import SectionContext from "."; import useExamEditorStore from "@/stores/examEditor"; import useSectionEdit from "../../Hooks/useSectionEdit"; @@ -22,9 +22,10 @@ interface Props { const ListeningContext: React.FC = ({ sectionId, module, listeningSection, level = false }) => { const { dispatch } = useExamEditorStore(); - const { genResult, state, generating, levelGenResults, levelGenerating } = useExamEditorStore( + const { genResult, state, generating, levelGenResults, levelGenerating, scriptLoading } = useExamEditorStore( (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! ); + const listeningPart = state as ListeningPart | LevelPart; const [isDialogDropdownOpen, setIsDialogDropdownOpen] = useState(false); @@ -51,9 +52,11 @@ const ListeningContext: React.FC = ({ sectionId, module, listeningSection }, }); - useEffect(()=> { + useEffect(() => { if (listeningPart.script == undefined) { - setScriptLocal(undefined); + setScriptLocal(undefined); + } else { + setScriptLocal(listeningPart.script); } }, [listeningPart]) @@ -93,8 +96,8 @@ const ListeningContext: React.FC = ({ sectionId, module, listeningSection // eslint-disable-next-line react-hooks/exhaustive-deps }, [levelGenResults]); - const renderContent = (editing: boolean, listeningSection?: number) => { - if (scriptLocal === undefined && !editing) { + const memoizedRenderContent = useCallback(() => { + if (scriptLocal === undefined && !editing && !scriptLoading) { return ( @@ -105,18 +108,23 @@ const ListeningContext: React.FC = ({ sectionId, module, listeningSection } return ( <> - {generating === "audio" ? () : ( + {(generating === "audio" || scriptLoading) ? ( + + ) : ( <> - {listeningPart.audio?.source && ( + {listeningPart.audio?.source !== undefined && ( )} )} - = ({ sectionId, module, listeningSection "h-5 w-5", `text-ielts-${module}` )} /> - { - listeningSection === undefined ? - ([1, 3].includes(sectionId) ? "Conversation" : "Monologue") : - ([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")} + + {listeningSection === undefined + ? ([1, 3].includes(sectionId) ? "Conversation" : "Monologue") + : ([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")} } @@ -136,15 +144,32 @@ const ListeningContext: React.FC = ({ sectionId, module, listeningSection setIsOpen={setIsDialogDropdownOpen} > + } ); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + scriptLoading, + generating, + listeningPart.audio?.source, + listeningPart.script, + sectionId, + module, + isDialogDropdownOpen, + setIsDialogDropdownOpen, + setScriptLocal, + level, + scriptLocal, + editing, + listeningSection + ]); return ( = ({ sectionId, module, listeningSection ([1, 3].includes(listeningSection) ? "Conversation" : "Monologue") } description={`Enter the section's ${(sectionId === 1 || sectionId === 3) ? "conversation" : "monologue"} or import your own`} - renderContent={renderContent} + renderContent={memoizedRenderContent} editing={editing} onSave={handleSave} onEdit={handleEdit} onDiscard={handleDiscard} module={module} context="listeningScript" - listeningSection={listeningSection} /> ); }; diff --git a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts index b48bafec..d4f2a121 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts +++ b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts @@ -68,7 +68,6 @@ export function generate( const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`; let body = null; - console.log(config.files); if (config.files && Object.keys(config.files).length > 0 && config.method === 'POST') { const formData = new FormData(); diff --git a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx index 5e0f115b..678e15c6 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx @@ -13,9 +13,10 @@ interface Props { generateFnc: (sectionId: number) => void className?: string; level?: boolean; + disabled?: boolean; } -const GenerateBtn: React.FC = ({ module, sectionId, genType, generateFnc, className, level = false }) => { +const GenerateBtn: React.FC = ({ module, sectionId, genType, generateFnc, className, level = false, disabled = false }) => { const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == sectionId)); const [loading, setLoading] = useState(false); @@ -24,7 +25,7 @@ const GenerateBtn: React.FC = ({ module, sectionId, genType, generateFnc, const levelGenerating = section?.levelGenerating; const levelGenResults = section?.levelGenResults; - useEffect(()=> { + useEffect(() => { const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType); if (loading !== gen) { setLoading(gen); @@ -42,8 +43,8 @@ const GenerateBtn: React.FC = ({ module, sectionId, genType, generateFnc, `bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40`, className )} - disabled={loading} - onClick={loading ? () => { } : () => generateFnc(sectionId)} + disabled={loading || disabled} + onClick={(loading || disabled) ? () => { } : () => generateFnc(sectionId)} > {loading ? (
diff --git a/src/components/ExamEditor/SettingsEditor/listening/AudioUpload.tsx b/src/components/ExamEditor/SettingsEditor/listening/AudioUpload.tsx new file mode 100644 index 00000000..9d26dde6 --- /dev/null +++ b/src/components/ExamEditor/SettingsEditor/listening/AudioUpload.tsx @@ -0,0 +1,148 @@ +import Button from '@/components/Low/Button'; +import Modal from '@/components/Modal'; +import dynamic from 'next/dynamic'; +import React, { useCallback, useState } from 'react'; +import { MdAudioFile, MdCloudUpload, MdDelete } from 'react-icons/md'; + +const Waveform = dynamic(() => import("@/components/Waveform"), { ssr: false }); + +interface AudioUploadProps { + isOpen: boolean; + audioFile: string | undefined; + setIsOpen: React.Dispatch>; + onFileSelect: (file: File | null) => void; + transcribeAudio: () => void; + setAudioUrl: React.Dispatch>; +} + +const AudioUpload: React.FC = ({ isOpen, audioFile, setIsOpen, onFileSelect, transcribeAudio, setAudioUrl }) => { + const [isDragging, setIsDragging] = useState(false); + const [error, setError] = useState(null); + + const handleDrag = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDragIn = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragOut = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const validateFile = (file: File): boolean => { + if (!file.type.startsWith('audio/')) { + setError('Please upload an audio file'); + return false; + } + setError(null); + return true; + }; + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + setError(null); + + const file = e.dataTransfer.files?.[0]; + if (file && validateFile(file)) { + onFileSelect(file); + } + }, [onFileSelect]); + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file && validateFile(file)) { + onFileSelect(file); + } + }; + + const handleRemoveAudio = () => { + onFileSelect(null); + }; + + return ( + setIsOpen(false)}> +
+ {!audioFile && ( +
+ +
+
+ {error ? ( + + ) : ( + + )} +
+
+

+ {error ? error : 'Upload Audio File'} +

+

+ Drag and drop your audio file here, or click to select +

+
+
+
+ )} + + {audioFile && ( +
+
+

Audio Upload

+ +
+ +
+ + +
+
+ )} +
+
+ ); +}; + +export default AudioUpload; \ No newline at end of file diff --git a/src/components/ExamEditor/SettingsEditor/listening/components.tsx b/src/components/ExamEditor/SettingsEditor/listening/components.tsx index 7e66da3d..9b57a934 100644 --- a/src/components/ExamEditor/SettingsEditor/listening/components.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening/components.tsx @@ -1,15 +1,20 @@ import Dropdown from "../Shared/SettingsDropdown"; import ExercisePicker from "../../ExercisePicker"; import GenerateBtn from "../Shared/GenerateBtn"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { generate } from "../Shared/Generate"; import { LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types"; import useExamEditorStore from "@/stores/examEditor"; -import { LevelPart, ListeningPart } from "@/interfaces/exam"; +import { LevelPart, ListeningPart, Script } from "@/interfaces/exam"; import Input from "@/components/Low/Input"; import axios from "axios"; import { toast } from "react-toastify"; import { playSound } from "@/utils/sound"; +import { FaFileUpload } from "react-icons/fa"; +import clsx from "clsx"; +import AudioUpload from "./AudioUpload"; +import { downloadBlob } from "@/utils/evaluation"; +import { BsArrowRepeat } from "react-icons/bs"; interface Props { localSettings: ListeningSectionSettings | LevelSectionSettings; @@ -25,9 +30,29 @@ const ListeningComponents: React.FC = ({ currentSection, localSettings, u const { focusedSection, difficulty, + sections } = useExamEditorStore(state => state.modules[currentModule]); + const [originalAudioUrl, setOriginalAudioUrl] = useState(); + const [audioUrl, setAudioUrl] = useState(); + const [isUploaderOpen, setIsUploaderOpen] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const generateScript = useCallback(() => { + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + setAudioUrl(undefined); + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { + sectionId: focusedSection, + module: "listening", + update: { + audio: undefined + } + } + }); + } generate( levelId ? levelId : focusedSection, "listening", @@ -124,8 +149,83 @@ const ListeningComponents: React.FC = ({ currentSection, localSettings, u // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentSection?.script, dispatch, level, levelId]); + + const handleFileSelect = (file: File | null) => { + if (file) { + const url = URL.createObjectURL(file); + setOriginalAudioUrl(url); + setAudioUrl(url); + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { + sectionId: focusedSection, + module: "listening", + update: { + audio: { + source: url, + repeatableTimes: 3 + } + } + } + }); + } else { + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + URL.revokeObjectURL(originalAudioUrl!); + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { + sectionId: focusedSection, + module: "listening", + update: { audio: undefined } + } + }); + } + setAudioUrl(undefined); + setOriginalAudioUrl(undefined); + } + }; + + + const transcribeAudio = async () => { + try { + setIsUploading(true); + dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {module: "listening", sectionId: focusedSection, field: "scriptLoading", value: true}}) + const formData = new FormData(); + const audioBlob = await downloadBlob(audioUrl!); + const audioFile = new File([audioBlob], "audio"); + formData.append("audio", audioFile); + + const config = { + headers: { + "Content-Type": "multipart/form-data", + }, + }; + + const response = await axios.post(`/api/transcribe`, formData, config); + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { + sectionId: focusedSection, + module: "listening", + update: { + script: (response.data as any).dialog as Script, + audio: { source: audioUrl!, repeatableTimes: 3 } + } + } + }); + + } catch (error) { + toast.error("An unexpected error has occurred, try again later!"); + } finally { + setIsUploading(false); + dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {module: "listening", sectionId: focusedSection, field: "scriptLoading", value: false}}) + } + }; + return ( <> + = ({ currentSection, localSettings, u sectionId={focusedSection} generateFnc={generateScript} level={level} + disabled={isUploading} />
- +
Or
+
+
+
+ Import your own audio file +
+
+ +
+
+
+ = ({ currentSection, localSettings, u module="listening" open={localSettings.isAudioGenerationOpen} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)} - disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0} + disabled={currentSection === undefined || currentSection.script === undefined || currentSection.exercises.length === 0 || audioUrl !== undefined} contentWrapperClassName={level ? `border border-ielts-listening` : ''} > -
+
Generate audio recording for this section diff --git a/src/components/ExamEditor/SettingsEditor/writing/index.tsx b/src/components/ExamEditor/SettingsEditor/writing/index.tsx index 6f52c78e..6ed9aebe 100644 --- a/src/components/ExamEditor/SettingsEditor/writing/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/writing/index.tsx @@ -61,7 +61,6 @@ const WritingSettings: React.FC = () => { exercises: sections.map((s, index) => { const exercise = s.state as WritingExercise; if (type === "academic" && index == 0 && academic_url) { - console.log("Added the URL"); exercise["attachment"] = { url: academic_url, description: "Visual Information" diff --git a/src/components/ExamEditor/index.tsx b/src/components/ExamEditor/index.tsx index 82a18c8c..e2a2d309 100644 --- a/src/components/ExamEditor/index.tsx +++ b/src/components/ExamEditor/index.tsx @@ -20,7 +20,7 @@ import { defaultSectionSettings } from "@/stores/examEditor/defaults"; import Button from "../Low/Button"; import ResetModule from "./ResetModule"; -const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; +const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"]; const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => { const { currentModule, dispatch } = useExamEditorStore(); diff --git a/src/components/ImportSummaries/Classroom.tsx b/src/components/ImportSummaries/Classroom.tsx new file mode 100644 index 00000000..704f7d2a --- /dev/null +++ b/src/components/ImportSummaries/Classroom.tsx @@ -0,0 +1,332 @@ +import React, { useState } from 'react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { + FaCheckCircle, + FaTimesCircle, + FaExclamationCircle, + FaInfoCircle, + FaUsers, + FaExclamationTriangle, + FaLock +} from 'react-icons/fa'; +import Modal from '../Modal'; +import UserTable from '../Tables/UserTable'; +import ParseExcelErrors from './ExcelError'; +import { errorsByRows } from '@/utils/excel.errors'; +import { ClassroomTransferState } from '../Imports/StudentClassroomTransfer'; +import { ExcelUserDuplicatesMap } from './User'; + +const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ state }) => { + const [showErrorsModal, setShowErrorsModal] = useState(false); + const [showDuplicatesModal, setShowDuplicatesModal] = useState(false); + const [showNotFoundModal, setShowNotFoundModal] = useState(false); + const [showOtherEntityModal, setShowOtherEntityModal] = useState(false); + const [showAlreadyInClassModal, setShowAlreadyInClassModal] = useState(false); + const [showNotOwnedModal, setShowNotOwnedModal] = useState(false); + + const errorCount = state.parsedExcel?.errors ? + Object.entries(errorsByRows(state.parsedExcel.errors)).length : 0; + + return ( + <> + + + + Import Summary + + + +
+
+ + + {`${state.parsedExcel?.rows?.length || 0} total spreadsheet rows`} + {state.parsedExcel && state.parsedExcel.rows && state.parsedExcel.rows.filter(row => row === null).length > 0 && ( + + {` (${state.parsedExcel.rows.filter((row: any) => row === null).length} empty)`} + + )} + +
+ +
+
+ {state.imports.length > 0 ? ( + + ) : ( + + )} + {`${state.imports.length} user${state.imports.length !== 1 ? 's' : ''} ready for transfer`} +
+
+ + {state.notFoundUsers.length > 0 && ( +
+
+ + {`${state.notFoundUsers.length} user${state.notFoundUsers.length !== 1 ? 's' : ''} not found`} +
+ +
+ )} + + {state.otherEntityUsers.length > 0 && ( +
+
+ + {`${state.otherEntityUsers.length} user${state.otherEntityUsers.length !== 1 ? 's' : ''} from different entities`} +
+ +
+ )} + + {state.alreadyInClass.length > 0 && ( +
+
+ + {`${state.alreadyInClass.length} user${state.alreadyInClass.length !== 1 ? 's' : ''} already in class`} +
+ +
+ )} + + {state.notOwnedClassrooms.length > 0 && ( +
+
+ + {`${state.notOwnedClassrooms.length} classroom${state.notOwnedClassrooms.length !== 1 ? 's' : ''} not owned`} +
+ +
+ )} + + {state.duplicatedRows && state.duplicatedRows.count > 0 && ( +
+
+ + {state.duplicatedRows.count} duplicate entries in file +
+ +
+ )} + + {errorCount > 0 && ( +
+
+ + {errorCount} invalid rows +
+ +
+ )} +
+ + {((state.duplicatedRows?.count ?? 0) > 0 || errorCount > 0 || + state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 || + state.notOwnedClassrooms.length > 0) && ( +
+
+
+ +
+
+

+ The following will be excluded from transfer: +

+
    + {state.notFoundUsers.length > 0 && ( +
  • +
    + {state.notFoundUsers.length} users not found +
    +
  • + )} + {state.otherEntityUsers.length > 0 && ( +
  • +
    + {state.otherEntityUsers.length} users from different entities +
    +
  • + )} + {state.notOwnedClassrooms.length > 0 && ( +
  • +
    + {state.notOwnedClassrooms.length} classrooms not owned: +
    + {state.notOwnedClassrooms.join(', ')} +
    +
    +
  • + )} + {state.duplicatedRows && state.duplicatedRows.count > 0 && ( +
  • +
    + {state.duplicatedRows.count} duplicate entries +
    +
  • + )} + {errorCount > 0 && ( +
  • +
    + {errorCount} rows with invalid information +
    +
  • + )} +
+
+
+
+ )} +
+
+ + {/* Modals */} + setShowErrorsModal(false)}> + <> +
+ +

Validation Errors

+
+ + +
+ + setShowNotFoundModal(false)}> + <> +
+ +

Users Not Found

+
+ + +
+ + setShowOtherEntityModal(false)}> + <> +
+ +

Users from Different Entities

+
+ + +
+ + setShowAlreadyInClassModal(false)}> + <> +
+ +

Users Already in Class

+
+ + +
+ + setShowNotOwnedModal(false)}> + <> +
+ +

Classrooms Not Owned

+
+
+ {state.notOwnedClassrooms.map(classroom => ( +
+ {classroom} +
+ ))} +
+ +
+ + setShowDuplicatesModal(false)}> + <> +
+ +

Duplicate Entries

+
+ {state.duplicatedRows && ( +
+ {(Object.keys(state.duplicatedRows.duplicates) as Array).map(field => { + const duplicates = Array.from(state.duplicatedRows!.duplicates[field].entries()) + .filter((entry): entry is [string, number[]] => entry[1].length > 1); + + if (duplicates.length === 0) return null; + + return ( +
+
+

+ {field} duplicates +

+ + {duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'} + +
+ +
+ {duplicates.map(([value, rows]) => ( +
+
+
+ {value} +
+ Appears in rows: + + {rows.join(', ')} + +
+
+ + {rows.length} occurrences + +
+
+ ))} +
+
+ ); + })} +
+ )} + +
+ + ); +}; + +export default ClassroomImportSummary; diff --git a/src/components/ImportSummaries/Codegen.tsx b/src/components/ImportSummaries/Codegen.tsx new file mode 100644 index 00000000..3e531d30 --- /dev/null +++ b/src/components/ImportSummaries/Codegen.tsx @@ -0,0 +1,215 @@ +import React, { useState, useMemo } from 'react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { + FaCheckCircle, + FaTimesCircle, + FaExclamationCircle, + FaInfoCircle, + FaExclamationTriangle +} from 'react-icons/fa'; +import Modal from '../Modal'; +import ParseExcelErrors from './ExcelError'; +import { errorsByRows, ExcelError } from '@/utils/excel.errors'; + +interface Props { + parsedExcel: { rows?: any[]; errors?: any[] }, + duplicateRows?: { duplicates: ExcelCodegenDuplicatesMap, count: number }, + infos: { email: string; name: string; passport_id: string }[]; +} + +export interface ExcelCodegenDuplicatesMap { + email: Map; + passport_id: Map; +} + +const CodeGenImportSummary: React.FC = ({ infos, parsedExcel, duplicateRows }) => { + const [showErrorsModal, setShowErrorsModal] = useState(false); + const [showDuplicatesModal, setShowDuplicatesModal] = useState(false); + + const errorCount = Object.entries(errorsByRows(parsedExcel.errors as ExcelError[])).length || 0; + + const fieldMapper = { + "passport_id": "Passport/National ID", + "email": "E-mail" + } + + return ( + <> + + + Import Summary + + +
+
+ + + {`${parsedExcel.rows?.length} total spreadsheet rows`} + {parsedExcel.rows && parsedExcel.rows.filter(row => row === null).length > 0 && ( + + {` (${parsedExcel.rows.filter(row => row === null).length} empty)`} + + )} + +
+
+
+ {infos.length > 0 ? ( + + ) : ( + + )} + {`${infos.length} new code${infos.length > 1 ? "s" : ''} to generate`} +
+
+ + {(duplicateRows && duplicateRows.count > 0) && ( +
+
+ + {duplicateRows.count} duplicate entries in file +
+ +
+ )} + + {errorCount > 0 && ( +
+
+ + {errorCount} invalid rows +
+ +
+ )} +
+ {((duplicateRows && duplicateRows.count > 0) || errorCount > 0) && ( +
+
+
+ +
+
+

+ The following will be excluded from import: +

+
    + {duplicateRows && duplicateRows.count > 0 && ( +
  • +
    + {duplicateRows.count} rows with duplicate values +
    +
    + {(Object.keys(duplicateRows.duplicates) as Array).map(field => { + const duplicates = Array.from(duplicateRows.duplicates[field].entries()) + .filter((entry) => entry[1].length > 1); + if (duplicates.length === 0) return null; + return ( +
    + {field}: rows { + duplicates.map(([_, rows]) => rows.join(', ')).join('; ') + } +
    + ); + })} +
    +
  • + )} + {errorCount > 0 && ( +
  • +
    + {errorCount} rows with invalid or missing information +
    +
    + Rows: {Object.keys(errorsByRows(parsedExcel.errors || [])).join(', ')} +
    +
  • + )} +
+
+
+
+ )} +
+
+ + setShowErrorsModal(false)}> + <> +
+ +

Validation Errors

+
+ + +
+ + setShowDuplicatesModal(false)}> + <> +
+ +

Duplicate Entries

+
+ {duplicateRows && + < div className="space-y-6"> + {(Object.keys(duplicateRows.duplicates) as Array).map(field => { + const duplicates = Array.from(duplicateRows!.duplicates[field].entries()) + .filter((entry): entry is [string, number[]] => entry[1].length > 1); + + if (duplicates.length === 0) return null; + + return ( +
+
+

+ {fieldMapper[field]} duplicates +

+ + {duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'} + +
+ +
+ {duplicates.map(([value, rows]) => ( +
+
+
+ {value} +
+ Appears in rows: + + {rows.join(', ')} + +
+
+ + {rows.length} occurrences + +
+
+ ))} +
+
+ ); + })} +
+ } + + + + ); +}; + +export default CodeGenImportSummary; diff --git a/src/components/UserImportSummary/ExcelError.tsx b/src/components/ImportSummaries/ExcelError.tsx similarity index 100% rename from src/components/UserImportSummary/ExcelError.tsx rename to src/components/ImportSummaries/ExcelError.tsx diff --git a/src/components/UserImportSummary/index.tsx b/src/components/ImportSummaries/User.tsx similarity index 97% rename from src/components/UserImportSummary/index.tsx rename to src/components/ImportSummaries/User.tsx index ad58846d..6168ae84 100644 --- a/src/components/UserImportSummary/index.tsx +++ b/src/components/ImportSummaries/User.tsx @@ -12,16 +12,16 @@ import { UserImport } from '@/interfaces/IUserImport'; import Modal from '../Modal'; import ParseExcelErrors from './ExcelError'; import { errorsByRows, ExcelError } from '@/utils/excel.errors'; -import UserTable from '../UserTable'; +import UserTable from '../Tables/UserTable'; interface Props { parsedExcel: { rows?: any[]; errors?: any[] }, newUsers: UserImport[], enlistedUsers: UserImport[], - duplicateRows?: { duplicates: DuplicatesMap, count: number } + duplicateRows?: { duplicates: ExcelUserDuplicatesMap, count: number } } -export interface DuplicatesMap { +export interface ExcelUserDuplicatesMap { studentID: Map; email: Map; passport_id: Map; @@ -139,7 +139,7 @@ const UserImportSummary: React.FC = ({ parsedExcel, newUsers, enlistedUse
)} - {(enlistedUsers.length > 0 || (duplicateRows && duplicateRows.count > 0) || errorCount > 0) && ( + {((duplicateRows && duplicateRows.count > 0) || errorCount > 0) && (
@@ -156,7 +156,7 @@ const UserImportSummary: React.FC = ({ parsedExcel, newUsers, enlistedUse {duplicateRows.count} rows with duplicate values
- {(Object.keys(duplicateRows.duplicates) as Array).map(field => { + {(Object.keys(duplicateRows.duplicates) as Array).map(field => { const duplicates = Array.from(duplicateRows.duplicates[field].entries()) .filter((entry) => entry[1].length > 1); if (duplicates.length === 0) return null; @@ -219,7 +219,7 @@ const UserImportSummary: React.FC = ({ parsedExcel, newUsers, enlistedUse
{duplicateRows && < div className="space-y-6"> - {(Object.keys(duplicateRows.duplicates) as Array).map(field => { + {(Object.keys(duplicateRows.duplicates) as Array).map(field => { const duplicates = Array.from(duplicateRows!.duplicates[field].entries()) .filter((entry): entry is [string, number[]] => entry[1].length > 1); diff --git a/src/components/Imports/StudentClassroomTransfer.tsx b/src/components/Imports/StudentClassroomTransfer.tsx new file mode 100644 index 00000000..ed31131d --- /dev/null +++ b/src/components/Imports/StudentClassroomTransfer.tsx @@ -0,0 +1,566 @@ +import { useEffect, useState } from "react"; +import Button from "../Low/Button"; +import Modal from "../Modal"; +import { useFilePicker } from "use-file-picker"; +import readXlsxFile from "read-excel-file"; +import countryCodes from "country-codes-list"; +import { ExcelUserDuplicatesMap } from "../ImportSummaries/User"; +import { UserImport } from "@/interfaces/IUserImport"; +import axios from "axios"; +import { toast } from "react-toastify"; +import { HiOutlineDocumentText } from "react-icons/hi"; +import { IoInformationCircleOutline } from "react-icons/io5"; +import { FaFileDownload } from "react-icons/fa"; +import clsx from "clsx"; +import { checkAccess } from "@/utils/permissions"; +import { Group, User } from "@/interfaces/user"; +import UserTable from "../Tables/UserTable"; +import ClassroomImportSummary from "../ImportSummaries/Classroom"; +import Select from "../Low/Select"; +import { EntityWithRoles } from "@/interfaces/entity"; + + +const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); + +export interface ClassroomTransferState { + stage: number; + parsedExcel: { rows?: any[]; errors?: any[] } | undefined; + duplicatedRows: { duplicates: ExcelUserDuplicatesMap, count: number } | undefined; + imports: UserImport[]; + notFoundUsers: UserImport[]; + otherEntityUsers: UserImport[]; + alreadyInClass: UserImport[]; + notOwnedClassrooms: string[]; + classroomsToCreate: string[]; +} + + +const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRoles[], onFinish: () => void; }> = ({ user, entities = [], onFinish }) => { + const [isLoading, setIsLoading] = useState(false); + const [entity, setEntity] = useState((entities || [])[0]?.id || undefined); + const [showHelp, setShowHelp] = useState(false); + + const [classroomTransferState, setClassroomTransferState] = useState({ + stage: 0, + parsedExcel: undefined, + duplicatedRows: undefined, + imports: [], + notFoundUsers: [], + otherEntityUsers: [], + alreadyInClass: [], + notOwnedClassrooms: [], + classroomsToCreate: [] + }) + + const { openFilePicker, filesContent, clear } = useFilePicker({ + accept: ".xlsx", + multiple: false, + readAs: "ArrayBuffer", + }); + + const schema = { + 'First Name': { + prop: 'firstName', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('First Name cannot be empty') + } + return true + } + }, + 'Last Name': { + prop: 'lastName', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Last Name cannot be empty') + } + return true + } + }, + 'Student ID': { + prop: 'studentID', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Student ID cannot be empty') + } + return true + } + }, + 'Passport/National ID': { + prop: 'passport_id', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Passport/National ID cannot be empty') + } + return true + } + }, + 'E-mail': { + prop: 'email', + required: true, + type: (value: any) => { + if (!value || value.trim() === '') { + throw new Error('Email cannot be empty') + } + if (!EMAIL_REGEX.test(value.trim())) { + throw new Error('Invalid Email') + } + return value + } + }, + 'Phone Number': { + prop: 'phone', + type: Number, + required: true, + validate: (value: number) => { + if (value === null || value === undefined || String(value).trim() === '') { + throw new Error('Phone Number cannot be empty') + } + return true + } + }, + 'Classroom Name': { + prop: 'group', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Classroom Name cannot be empty') + } + return true + } + }, + 'Country': { + prop: 'country', + type: (value: any) => { + if (!value || value.trim() === '') { + throw new Error('Country cannot be empty') + } + const validCountry = countryCodes.findOne("countryCode" as any, value.toUpperCase()) || + countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === value.toLowerCase()); + if (!validCountry) { + throw new Error('Invalid Country/Country Code') + } + return validCountry; + }, + required: true + } + } + + useEffect(() => { + if (filesContent.length > 0) { + const file = filesContent[0]; + readXlsxFile( + file.content, { schema, ignoreEmptyRows: false }) + .then((data) => { + setClassroomTransferState((prev) => ({...prev, parsedExcel: data})) + console.log(data); + }); + + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filesContent]); + + // Stage 1 - Excel Parsing + // - Find duplicate rows + // - Parsing errors + // - Set the data + useEffect(() => { + if (classroomTransferState.parsedExcel && classroomTransferState.parsedExcel.rows) { + const duplicates: ExcelUserDuplicatesMap = { + studentID: new Map(), + email: new Map(), + passport_id: new Map(), + phone: new Map() + }; + const duplicateValues = new Set(); + const duplicateRowIndices = new Set(); + + const errorRowIndices = new Set( + classroomTransferState.parsedExcel.errors?.map(error => error.row) || [] + ); + + classroomTransferState.parsedExcel.rows.forEach((row, index) => { + if (!errorRowIndices.has(index + 2)) { + (Object.keys(duplicates) as Array).forEach(field => { + if (row !== null) { + const value = row[field]; + if (value) { + if (!duplicates[field].has(value)) { + duplicates[field].set(value, [index + 2]); + } else { + const existingRows = duplicates[field].get(value); + if (existingRows) { + existingRows.push(index + 2); + duplicateValues.add(value); + existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum)); + } + } + } + } + }); + } + }); + + const infos = classroomTransferState.parsedExcel.rows + .map((row, index) => { + if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) { + return undefined; + } + const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row; + if (!email || !EMAIL_REGEX.test(email.toString().trim())) { + return undefined; + } + + return { + email: email.toString().trim().toLowerCase(), + name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), + passport_id: passport_id?.toString().trim() || undefined, + groupName: group, + studentID, + demographicInformation: { + country: country?.countryCode, + passport_id: passport_id?.toString().trim() || undefined, + phone: phone.toString(), + }, + entity: undefined, + type: undefined + } as UserImport; + }) + .filter((item): item is UserImport => item !== undefined); + console.log(infos); + + // On import reset state except excel parsing + setClassroomTransferState((prev) => ({ + ...prev, + stage: 1, + duplicatedRows: { duplicates, count: duplicateRowIndices.size }, + imports: infos, + notFoundUsers: [], + otherEntityUsers: [], + notOwnedClassrooms: [], + classroomsToCreate: [], + })); + } + }, [classroomTransferState.parsedExcel]); + + // Stage 2 - Student Filter + // - Filter non existant students + // - Filter non entity students + // - Filter already in classroom students + useEffect(() => { + const emails = classroomTransferState.imports.map((i) => i.email); + const crossRefUsers = async () => { + try { + console.log(user.entities); + const { data: nonExistantUsers } = await axios.post("/api/users/controller?op=dontExist", { emails }); + const { data: nonEntityUsers } = await axios.post("/api/users/controller?op=entityCheck", { entities: user.entities, emails }); + const { data: alreadyPlaced } = await axios.post("/api/users/controller?op=crossRefClassrooms", { + sets: classroomTransferState.imports.map((info) => ({ + email: info.email, + classroom: info.groupName + })) + }); + + const excludeEmails = new Set([ + ...nonExistantUsers, + ...nonEntityUsers, + ...alreadyPlaced + ]); + + const filteredImports = classroomTransferState.imports.filter(i => !excludeEmails.has(i.email)); + + const nonExistantEmails = new Set(nonExistantUsers); + const nonEntityEmails = new Set(nonEntityUsers); + const alreadyPlacedEmails = new Set(alreadyPlaced); + + setClassroomTransferState((prev) => ({ + ...prev, + stage: 2, + imports: filteredImports, + notFoundUsers: classroomTransferState.imports.filter(i => nonExistantEmails.has(i.email)), + otherEntityUsers: classroomTransferState.imports.filter(i => nonEntityEmails.has(i.email)), + alreadyInClass: classroomTransferState.imports.filter(i => alreadyPlacedEmails.has(i.email)), + })); + } catch (error) { + toast.error("Something went wrong, please try again later!"); + } + } + + if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 1) { + crossRefUsers(); + } + + }, [classroomTransferState.imports, user.entities, classroomTransferState.stage]) + + // Stage 3 - Classroom Filter + // - See if there are classrooms with same name but different admin + // - Find which new classrooms need to be created + useEffect(() => { + const crossRefClassrooms = async () => { + const classrooms = new Set(classroomTransferState.imports.map((i) => i.groupName)); + + try { + const { data: notOwnedClassroomsSameName } = await axios.post("/api/groups/controller?op=crossRefOwnership", { + userId: user.id, + classrooms + }); + + setClassroomTransferState((prev) => ({ + ...prev, + stage: 3, + notOwnedClassrooms: notOwnedClassroomsSameName, + classroomsToCreate: Array.from(classrooms).filter( + (name) => !new Set(notOwnedClassroomsSameName).has(name) + ) + })) + } catch (error) { + toast.error("Something went wrong, please try again later!"); + } + }; + if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) { + crossRefClassrooms(); + } + }, [classroomTransferState.imports, classroomTransferState.stage, user.id]) + + + const clearAndReset = () => { + setIsLoading(false); + setClassroomTransferState({ + stage: 0, + parsedExcel: undefined, + duplicatedRows: undefined, + imports: [], + notFoundUsers: [], + otherEntityUsers: [], + alreadyInClass: [], + notOwnedClassrooms: [], + classroomsToCreate: [] + }); + clear(); + }; + + + const createNewGroupsAndAssignStudents = async () => { + if (!confirm(`You are about to assign ${classroomTransferState.imports.length} to new classrooms, are you sure you want to continue?`)) { + return; + } + + if (classroomTransferState.imports.length === 0) { + clearAndReset(); + return; + } + + try { + setIsLoading(true); + + const groupedUsers = classroomTransferState.imports.reduce((acc, user) => { + if (!acc[user.groupName]) { + acc[user.groupName] = []; + } + acc[user.groupName].push(user); + return acc; + }, {} as Record); + + const newGroupUsers = Object.fromEntries( + Object.entries(groupedUsers) + .filter(([groupName]) => classroomTransferState.classroomsToCreate.includes(groupName)) + ); + + const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => { + const groupData: Partial = { + admin: user.id, + name: groupName, + participants: users.map(user => user.id), + entity: entity + }; + return axios.post('/api/groups', groupData); + }); + + const existingGroupUsers = Object.fromEntries( + Object.entries(groupedUsers) + .filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName)) + ); + + const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', { + names: Object.keys(existingGroupUsers), + userEmails: Object.values(existingGroupUsers).flat().map(user => user.email) + }).then(response => response.data); + + const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => { + acc[groupNameToId[groupName]] = users; + return acc; + }, {} as Record); + + const updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => { + const userIds = users.map(user => userEmailToId[user.email]); + return axios.patch(`/api/groups/${groupId}`, { + participants: userIds + }); + }); + + await Promise.all([ + ...createGroupPromises, + ...updatePromises + ]); + + toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`); + onFinish(); + } catch (error) { + console.error(error); + toast.error("Something went wrong, please try again later!"); + } finally { + clearAndReset(); + } + }; + + const handleTemplateDownload = () => { + const fileName = "UsersTemplate.xlsx"; + const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`; + + const link = document.createElement('a'); + link.href = url; + + link.download = fileName; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( + <> + setShowHelp(false)}> + <> +
Excel File Format
+
+
+
+ +

+ The uploaded document must: +

+
+
    +
  • + be an Excel .xlsx document. +
  • +
  • + only have a single spreadsheet with the following exact same name columns: +
    + + + + + + + + + + + + + +
    First NameLast NameStudent IDPassport/National IDE-mailPhone NumberClassroom NameCountry
    +
    +
  • +
+
+
+
+ +

+ Note that: +

+
+
    +
  • + all incorrect e-mails will be ignored. +
  • +
  • + all non registered users will be ignored. +
  • +
  • + all students that already are present in the destination classroom will be ignored. +
  • +
  • + all registered students that are not associated to your institution will be ignored. +
  • +
  • + all rows which contain duplicate values in the columns: "Student ID", "Passport/National ID", "E-mail", will be ignored. +
  • +
+
+
+

+ {`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`} +

+
+
+ + + +
+
+ +
+
+
+
+
+ + +
+ +
+
+ + setGlobalFilter(e.target.value)} + placeholder="Search ..." + className="p-2 border rounded flex-grow" + /> + +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row, index, array) => { + const isLastRow = index === array.length - 1; + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ); + })} + + ); + })} + +
+ {header.isPlaceholder ? null : ( +
+ + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + + {{ + asc: ' 🔼', + desc: ' 🔽', + }[header.column.getIsSorted() as string] ?? null} + +
+ )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ + + + Page{' '} + + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} + + + + +
+
+ ); +}; + +export default CodegenTable; diff --git a/src/components/UserTable.tsx b/src/components/Tables/UserTable.tsx similarity index 99% rename from src/components/UserTable.tsx rename to src/components/Tables/UserTable.tsx index 79d597df..22a301eb 100644 --- a/src/components/UserTable.tsx +++ b/src/components/Tables/UserTable.tsx @@ -9,7 +9,7 @@ import { getFilteredRowModel, FilterFn, } from '@tanstack/react-table'; -import { UserImport } from "../interfaces/IUserImport"; +import { UserImport } from "../../interfaces/IUserImport"; const globalFilterFn: FilterFn = (row, columnId, filterValue: string) => { const value = row.getValue(columnId); diff --git a/src/components/Waveform.tsx b/src/components/Waveform.tsx index 8af5adb3..84ec5789 100644 --- a/src/components/Waveform.tsx +++ b/src/components/Waveform.tsx @@ -1,15 +1,20 @@ +import { Switch } from "@headlessui/react"; +import clsx from "clsx"; import React, { useEffect, useRef, useState } from "react"; import { BsPauseFill, BsPlayFill, BsScissors, BsTrash } from "react-icons/bs"; +import { MdAllInclusive } from "react-icons/md"; +import { BsFillFileEarmarkMusicFill } from "react-icons/bs"; import WaveSurfer from "wavesurfer.js"; // @ts-ignore import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.min.js'; +import { toast } from "react-toastify"; interface Props { audio: string; waveColor: string; progressColor: string; variant?: 'exercise' | 'edit'; - onCutsChange?: (cuts: AudioCut[]) => void; + setAudioUrl?: React.Dispatch>; } interface AudioCut { @@ -18,19 +23,37 @@ interface AudioCut { end: number; } -const Waveform = ({ - audio, - waveColor, - progressColor, +const Waveform = ({ + audio, + waveColor, + progressColor, variant = 'exercise', - onCutsChange + setAudioUrl }: Props) => { const containerRef = useRef(null); + const previewContainerRef = useRef(null); const waveSurferRef = useRef(null); + const previewWaveSurferRef = useRef(null); + const audioContextRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); - const [cuts, setCuts] = useState([]); - const [currentRegion, setCurrentRegion] = useState(null); + const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); + const [currentCut, setCurrentCut] = useState(null); const [duration, setDuration] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [cutAudioUrl, setCutAudioUrl] = useState(null); + const [useFullAudio, setUseFullAudio] = useState(true); + + const cleanupPreview = () => { + if (cutAudioUrl) { + URL.revokeObjectURL(cutAudioUrl); + setCutAudioUrl(null); + } + if (previewWaveSurferRef.current) { + previewWaveSurferRef.current.destroy(); + previewWaveSurferRef.current = null; + } + setIsPreviewPlaying(false); + }; useEffect(() => { const waveSurfer = WaveSurfer.create({ @@ -67,51 +90,183 @@ const Waveform = ({ waveSurfer.on("finish", () => setIsPlaying(false)); if (variant === 'edit') { - waveSurfer.on('region-created', (region) => { - setCurrentRegion(region); + const regions = waveSurfer.regions.list; + Object.keys(regions).forEach(id => { + if (id !== region.id) { + regions[id].remove(); + } + }); + cleanupPreview(); + const newCut: AudioCut = { id: region.id, start: region.start, end: region.end }; - setCuts(prev => [...prev, newCut]); - onCutsChange?.([...cuts, newCut]); + setCurrentCut(newCut); }); - waveSurfer.on('region-updated', (region) => { - setCuts(prev => prev.map(cut => - cut.id === region.id - ? { ...cut, start: region.start, end: region.end } - : cut - )); - onCutsChange?.(cuts.map(cut => - cut.id === region.id - ? { ...cut, start: region.start, end: region.end } - : cut - )); + const updatedCut: AudioCut = { + id: region.id, + start: region.start, + end: region.end + }; + setCurrentCut(updatedCut); + cleanupPreview(); }); } return () => { waveSurfer.destroy(); + cleanupPreview(); + if (audioContextRef.current?.state !== 'closed') { + audioContextRef.current?.close(); + } }; - - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [audio, progressColor, waveColor, variant]); + useEffect(() => { + if (cutAudioUrl && previewContainerRef.current) { + const previewWaveSurfer = WaveSurfer.create({ + container: previewContainerRef.current, + responsive: true, + cursorWidth: 0, + height: 48, + waveColor, + progressColor, + barGap: 5, + barWidth: 8, + barRadius: 4, + fillParent: true, + hideScrollbar: true, + normalize: true, + autoCenter: true, + barMinHeight: 4, + }); + + previewWaveSurfer.load(cutAudioUrl); + previewWaveSurfer.on("finish", () => setIsPreviewPlaying(false)); + previewWaveSurferRef.current = previewWaveSurfer; + + return () => { + previewWaveSurfer.destroy(); + previewWaveSurferRef.current = null; + }; + } + }, [cutAudioUrl, waveColor, progressColor]); + const handlePlayPause = () => { setIsPlaying(prev => !prev); waveSurferRef.current?.playPause(); }; - const handleDeleteRegion = (cutId: string) => { - const region = waveSurferRef.current?.regions?.list[cutId]; - if (region) { - region.remove(); - setCuts(prev => prev.filter(cut => cut.id !== cutId)); - onCutsChange?.(cuts.filter(cut => cut.id !== cutId)); + const handlePreviewPlayPause = () => { + setIsPreviewPlaying(prev => !prev); + previewWaveSurferRef.current?.playPause(); + }; + + const handleDeleteRegion = () => { + if (currentCut && waveSurferRef.current?.regions?.list[currentCut.id]) { + waveSurferRef.current.regions.list[currentCut.id].remove(); + setCurrentCut(null); + cleanupPreview(); + } + }; + + const applyCuts = async () => { + if (!waveSurferRef.current || !currentCut) return; + + setIsProcessing(true); + try { + if (!audioContextRef.current) { + audioContextRef.current = new AudioContext(); + } + + const response = await fetch(audio); + const arrayBuffer = await response.arrayBuffer(); + const originalBuffer = await audioContextRef.current.decodeAudioData(arrayBuffer); + + const duration = currentCut.end - currentCut.start; + const newBuffer = audioContextRef.current.createBuffer( + originalBuffer.numberOfChannels, + Math.ceil(audioContextRef.current.sampleRate * duration), + audioContextRef.current.sampleRate + ); + + for (let channel = 0; channel < originalBuffer.numberOfChannels; channel++) { + const newChannelData = newBuffer.getChannelData(channel); + const originalChannelData = originalBuffer.getChannelData(channel); + + const startSample = Math.floor(currentCut.start * audioContextRef.current.sampleRate); + const endSample = Math.floor(currentCut.end * audioContextRef.current.sampleRate); + const cutLength = endSample - startSample; + + for (let i = 0; i < cutLength; i++) { + newChannelData[i] = originalChannelData[startSample + i]; + } + } + + const offlineContext = new OfflineAudioContext( + newBuffer.numberOfChannels, + newBuffer.length, + newBuffer.sampleRate + ); + + const source = offlineContext.createBufferSource(); + source.buffer = newBuffer; + source.connect(offlineContext.destination); + source.start(); + + const renderedBuffer = await offlineContext.startRendering(); + + const wavBlob = await new Promise((resolve) => { + const numberOfChannels = renderedBuffer.numberOfChannels; + const length = renderedBuffer.length * numberOfChannels * 2; + const buffer = new ArrayBuffer(44 + length); + const view = new DataView(buffer); + + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + length, true); + writeString(view, 8, 'WAVE'); + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, numberOfChannels, true); + view.setUint32(24, renderedBuffer.sampleRate, true); + view.setUint32(28, renderedBuffer.sampleRate * numberOfChannels * 2, true); + view.setUint16(32, numberOfChannels * 2, true); + view.setUint16(34, 16, true); + writeString(view, 36, 'data'); + view.setUint32(40, length, true); + + let offset = 44; + for (let i = 0; i < renderedBuffer.length; i++) { + for (let channel = 0; channel < numberOfChannels; channel++) { + const sample = renderedBuffer.getChannelData(channel)[i]; + view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true); + offset += 2; + } + } + + resolve(new Blob([buffer], { type: 'audio/wav' })); + }); + + const newUrl = URL.createObjectURL(wavBlob); + + if (cutAudioUrl) { + URL.revokeObjectURL(cutAudioUrl); + } + setCutAudioUrl(newUrl); + setUseFullAudio(false); + setAudioUrl?.(newUrl); + + } catch (error) { + console.error('Error applying cuts:', error); + } finally { + setIsProcessing(false); } }; @@ -121,51 +276,116 @@ const Waveform = ({ return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; + const writeString = (view: DataView, offset: number, string: string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + + const switchAudio = () => { + if (!cutAudioUrl) { + toast.info("Apply an audio cut first!"); + } else { + setUseFullAudio(!useFullAudio); + setAudioUrl?.(useFullAudio ? audio : cutAudioUrl!) + } + } + return (
-
- {isPlaying ? ( - - ) : ( - - )} +
+
+ {isPlaying ? ( + + ) : ( + + )} - {variant === 'edit' && duration > 0 && ( -
- Total Duration: {formatTime(duration)} + {variant === 'edit' && duration > 0 && ( +
+ Total Duration: {formatTime(duration)} +
+ )} +
+ {variant === 'edit' && ( +
+ + switchAudio()} + className={clsx( + "relative inline-flex h-[30px] w-[58px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75", + useFullAudio ? "bg-green-200" : "bg-blue-200" + )} + > + +
)}
- {variant === 'edit' && cuts.length > 0 && ( + {variant === 'edit' && currentCut && (
-

Audio Cuts

-
- {cuts.map((cut) => ( -
-
- {formatTime(cut.start)} - {formatTime(cut.end)} -
- -
- ))} +
+

Selected Region

+
+
+
+ {formatTime(currentCut.start)} - {formatTime(currentCut.end)} +
+ +
+
+ )} + + {cutAudioUrl && ( +
+
+

Cut Preview

+ {isPreviewPlaying ? ( + + ) : ( + + )} +
+
)}
diff --git a/src/interfaces/IUserImport.ts b/src/interfaces/IUserImport.ts index e9b0088b..ddd5067d 100644 --- a/src/interfaces/IUserImport.ts +++ b/src/interfaces/IUserImport.ts @@ -7,9 +7,9 @@ export interface UserImport { email: string; name: string; passport_id: string; - type: Type; + type?: Type; groupName: string; - entity: string; + entity?: string; studentID: string; demographicInformation: { country: string; diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 5273c849..e69c6050 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -3,7 +3,13 @@ import { Module } from "."; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Variant = "full" | "partial"; export type InstructorGender = "male" | "female" | "varied"; -export type Difficulty = "easy" | "medium" | "hard"; + +export type Difficulty = BasicDifficulty | CEFRLevels; + +// Left easy, medium and hard to support older exam versions +export type BasicDifficulty = "easy" | "medium" | "hard"; +export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2"; + export interface ExamBase { id: string; diff --git a/src/pages/(admin)/BatchCodeGenerator.tsx b/src/pages/(admin)/BatchCodeGenerator.tsx index 166103c1..8b9b76f6 100644 --- a/src/pages/(admin)/BatchCodeGenerator.tsx +++ b/src/pages/(admin)/BatchCodeGenerator.tsx @@ -21,6 +21,11 @@ import { PermissionType } from "@/interfaces/permissions"; import usePermissions from "@/hooks/usePermissions"; import { EntityWithRoles } from "@/interfaces/entity"; import Select from "@/components/Low/Select"; +import CodeGenImportSummary, { ExcelCodegenDuplicatesMap } from "@/components/ImportSummaries/Codegen"; +import { FaFileDownload } from "react-icons/fa"; +import { IoInformationCircleOutline } from "react-icons/io5"; +import { HiOutlineDocumentText } from "react-icons/hi"; +import CodegenTable from "@/components/Tables/CodeGenTable"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); @@ -74,7 +79,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [type, setType] = useState("student"); const [showHelp, setShowHelp] = useState(false); - const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) + const [entity, setEntity] = useState((entities || [])[0]?.id || undefined); + const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined }); + const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>(); const { openFilePicker, filesContent, clear } = useFilePicker({ accept: ".xlsx", @@ -86,46 +93,123 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss if (!isExpiryDateEnabled) setExpiryDate(null); }, [isExpiryDateEnabled]); + const schema = { + 'First Name': { + prop: 'firstName', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('First Name cannot be empty') + } + return true + } + }, + 'Last Name': { + prop: 'lastName', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Last Name cannot be empty') + } + return true + } + }, + 'Passport/National ID': { + prop: 'passport_id', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Passport/National ID cannot be empty') + } + return true + } + }, + 'E-mail': { + prop: 'email', + required: true, + type: (value: any) => { + if (!value || value.trim() === '') { + throw new Error('Email cannot be empty') + } + if (!EMAIL_REGEX.test(value.trim())) { + throw new Error('Invalid Email') + } + return value + } + } + } + useEffect(() => { if (filesContent.length > 0) { const file = filesContent[0]; - readXlsxFile(file.content).then((rows) => { - try { - const information = uniqBy( - rows - .map((row) => { - const [firstName, lastName, country, passport_id, email, phone] = row as string[]; - return EMAIL_REGEX.test(email.toString().trim()) - ? { - email: email.toString().trim().toLowerCase(), - name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), - passport_id: passport_id?.toString().trim() || undefined, - } - : undefined; - }) - .filter((x) => !!x) as typeof infos, - (x) => x.email, - ); - - if (information.length === 0) { - toast.error( - "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", - ); - return clear(); - } - - setInfos(information); - } catch { - toast.error( - "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", - ); - return clear(); - } - }); + readXlsxFile( + file.content, { schema, ignoreEmptyRows: false }) + .then((data) => { + setParsedExcel(data) + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [filesContent]); + useEffect(() => { + if (parsedExcel.rows) { + const duplicates: ExcelCodegenDuplicatesMap = { + email: new Map(), + passport_id: new Map(), + }; + const duplicateValues = new Set(); + const duplicateRowIndices = new Set(); + + const errorRowIndices = new Set( + parsedExcel.errors?.map(error => error.row) || [] + ); + + parsedExcel.rows.forEach((row, index) => { + if (!errorRowIndices.has(index + 2)) { + (Object.keys(duplicates) as Array).forEach(field => { + if (row !== null) { + const value = row[field]; + if (value) { + if (!duplicates[field].has(value)) { + duplicates[field].set(value, [index + 2]); + } else { + const existingRows = duplicates[field].get(value); + if (existingRows) { + existingRows.push(index + 2); + duplicateValues.add(value); + existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum)); + } + } + } + } + }); + } + }); + + const info = parsedExcel.rows + .map((row, index) => { + if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) { + return undefined; + } + const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row; + if (!email || !EMAIL_REGEX.test(email.toString().trim())) { + return undefined; + } + + return { + email: email.toString().trim().toLowerCase(), + name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), + passport_id: passport_id?.toString().trim() || undefined, + }; + }).filter((x) => !!x) as typeof infos; + + setInfos(info); + } + }, [entity, parsedExcel, type]); + const generateAndInvite = async () => { const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email)); const existingUsers = infos @@ -199,40 +283,106 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss }); }; + const handleTemplateDownload = () => { + const fileName = "BatchCodeTemplate.xlsx"; + const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`; + + const link = document.createElement('a'); + link.href = url; + + link.download = fileName; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + return ( <> - setShowHelp(false)} title="Excel File Format"> -
- Please upload an Excel file with the following format: - - - - - - - - - - - -
First NameLast NameCountryPassport/National IDE-mailPhone Number
- - Notes: -
    -
  • - All incorrect e-mails will be ignored;
  • -
  • - All already registered e-mails will be ignored;
  • -
  • - You may have a header row with the format above, however, it is not necessary;
  • -
  • - All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
  • -
-
-
+ setShowHelp(false)}> + <> +
Excel File Format
+
+
+
+ +

+ The uploaded document must: +

+
+
    +
  • + be an Excel .xlsx document. +
  • +
  • + only have a single spreadsheet with the following exact same name columns: +
    + + + + + + + + + +
    First NameLast NamePassport/National IDE-mail
    +
    +
  • +
+
+
+
+ +

+ Note that: +

+
+
    +
  • + all incorrect e-mails will be ignored. +
  • +
  • + all already registered e-mails will be ignored. +
  • +
  • + all rows which contain duplicate values in the columns: "Passport/National ID", "E-mail", will be ignored. +
  • +
  • + all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below. +
  • +
+
+
+

+ {`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`} +

+
+
+ + + +
+
+
-
setShowHelp(true)}> - -
+
+ } +
diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index fdbc61e6..00f7ff3f 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -167,7 +167,6 @@ export default function Generation({ id, user, exam, examModule, permissions }: defaultValue={title} required /> - {/**/} const state: ModuleState = { examLabel: defaultExamLabel(module), minTimer, - difficulty: sample(["easy", "medium", "hard"] as Difficulty[])!, + difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!, isPrivate: false, sectionLabels: sectionLabels(module), expandedSections: [1], diff --git a/src/stores/examEditor/reducers/sectionReducer.ts b/src/stores/examEditor/reducers/sectionReducer.ts index 14daeef9..376f51dc 100644 --- a/src/stores/examEditor/reducers/sectionReducer.ts +++ b/src/stores/examEditor/reducers/sectionReducer.ts @@ -6,7 +6,7 @@ import { reorderSection } from "../reorder/global"; export type SectionActions = | { type: 'UPDATE_SECTION_SINGLE_FIELD'; payload: { module: Module; sectionId: number; field: string; value: any } } - | { type: 'UPDATE_SECTION_SETTINGS'; payload: { sectionId: number; module: Module; update: Partial; } } + | { type: 'UPDATE_SECTION_SETTINGS'; payload: { sectionId: number; module: Module; update: Partial; } } | { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; module: Module; update: Partial
; } } | { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, module: Module; sectionId: number; } }; diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index 22f66938..2635437e 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -1,4 +1,4 @@ -import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; +import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, Script, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { Module } from "@/interfaces"; import Option from "@/interfaces/option"; @@ -36,6 +36,9 @@ export interface ListeningSectionSettings extends SectionSettings { isAudioGenerationOpen: boolean; listeningTopic: string; isListeningTopicOpen: boolean; + uploadedAudioURL: string | undefined; + audioCutURL: string | undefined; + useEntireAudioFile: boolean; } export interface WritingSectionSettings extends SectionSettings { @@ -90,7 +93,7 @@ export type ExamPart = ListeningPart | ReadingPart | LevelPart; export interface SectionState { sectionId: number; - settings: SectionSettings | ReadingSectionSettings; + settings: SectionSettings; state: Section; expandedSubSections: number[]; generating: Generating; @@ -102,6 +105,7 @@ export interface SectionState { speakingSection?: number; readingSection?: number; listeningSection?: number; + scriptLoading: boolean; } export interface ModuleState { diff --git a/src/utils/exams.be.ts b/src/utils/exams.be.ts index a3320c3d..38c285b5 100644 --- a/src/utils/exams.be.ts +++ b/src/utils/exams.be.ts @@ -1,6 +1,6 @@ import { collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and } from "firebase/firestore"; import { groupBy, shuffle } from "lodash"; -import { Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam"; +import { CEFRLevels, Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam"; import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user"; import { Module } from "@/interfaces"; import { getCorporateUser } from "@/resources/user"; @@ -128,9 +128,42 @@ const filterByDifficulty = async (db: Db, exams: Exam[], module: Module, userID? const user = await db.collection("users").findOne({ id: userID }); if (!user) return exams; - const difficulty = user.levels[module] <= 3 ? "easy" : user.levels[module] <= 6 ? "medium" : "hard"; + const basicDifficulty = user.levels[module] <= 3 ? "easy" : user.levels[module] <= 6 ? "medium" : "hard"; + + let CEFRLevel: CEFRLevels; + + // Adjust the levels if necessary + switch (user.levels[module]) { + case 1: + case 2: + CEFRLevel = "A1"; + break; + case 3: + case 4: + CEFRLevel = "A2"; + break; + case 4: + case 5: + CEFRLevel = "B1"; + break; + case 6: + CEFRLevel = "B2"; + break; + case 7: + case 8: + CEFRLevel = "C1"; + break; + case 9: + CEFRLevel = "C2"; + break; + default: + CEFRLevel = "B1"; + } + + const filteredExams = exams.filter((exam) => + exam.difficulty === basicDifficulty || exam.difficulty === CEFRLevel + ); - const filteredExams = exams.filter((exam) => exam.difficulty === difficulty); return filteredExams.length === 0 ? exams : filteredExams; };