Merge pull request #445 from learnhouse/feat/optimize-mobile-blocks

Optimize Dynamic Activities Blocks for Mobile
This commit is contained in:
Badr B. 2025-02-26 17:53:10 +01:00 committed by GitHub
commit d567be44d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1343 additions and 344 deletions

View file

@ -44,6 +44,12 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
});
const handleFileChange = async (event: any) => {
// Check if user is authenticated
if (!access_token) {
setError('Authentication required. Please sign in to upload files.');
return;
}
const file = event.target.files[0]
setLocalUploadFile(file)
@ -69,21 +75,30 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
setIsLoading(false)
setError('')
}
}
async function getAssignmentTaskSubmissionFromUserUI() {
if (!access_token) {
// Silently fail if not authenticated
return;
}
if (assignmentTaskUUID) {
const res = await getAssignmentTaskSubmissionsMe(assignmentTaskUUID, assignment.assignment_object.assignment_uuid, access_token);
if (res.success) {
setUserSubmissions(res.data.task_submission);
setInitialUserSubmissions(res.data.task_submission);
}
}
}
const submitFC = async () => {
// Check if user is authenticated
if (!access_token) {
toast.error('Authentication required. Please sign in to submit your task.');
return;
}
// Save the quiz to the server
const values = {
task_submission: userSubmissions,
@ -105,13 +120,17 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
};
async function getAssignmentTaskUI() {
if (!access_token) {
// Silently fail if not authenticated
return;
}
if (assignmentTaskUUID) {
const res = await getAssignmentTask(assignmentTaskUUID, access_token);
if (res.success) {
setAssignmentTask(res.data);
setAssignmentTaskOutsideProvider(res.data);
}
}
}
@ -129,6 +148,11 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
/* GRADING VIEW CODE */
const [userSubmissionObject, setUserSubmissionObject] = useState<any>(null);
async function getAssignmentTaskSubmissionFromIdentifiedUserUI() {
if (!access_token) {
// Silently fail if not authenticated
return;
}
if (assignmentTaskUUID && user_id) {
const res = await getAssignmentTaskSubmissionsUser(assignmentTaskUUID, user_id, assignment.assignment_object.assignment_uuid, access_token);
if (res.success) {
@ -136,7 +160,6 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
setUserSubmissionObject(res.data);
setInitialUserSubmissions(res.data.task_submission);
}
}
}
@ -186,31 +209,29 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
return (
<AssignmentBoxUI submitFC={submitFC} showSavingDisclaimer={showSavingDisclaimer} view={view} gradeCustomFC={gradeCustomFC} currentPoints={userSubmissionObject?.grade} maxPoints={assignmentTaskOutsideProvider?.max_grade_value} type="file">
{view === 'teacher' && (
<div className='flex py-5 text-sm justify-center mx-auto space-x-2 text-slate-500'>
<Info size={20} />
<div className='flex flex-col sm:flex-row py-5 sm:py-6 text-xs sm:text-sm justify-center mx-auto space-y-2 sm:space-y-0 sm:space-x-3 text-slate-600 px-4 sm:px-2 text-center sm:text-left bg-slate-50 rounded-lg border border-slate-100'>
<Info size={18} className="mx-auto sm:mx-0 text-slate-500" />
<p>User will be able to submit a file for this task, you'll be able to review it in the Submissions Tab</p>
</div>
)}
{view === 'custom-grading' && (
<div className='flex flex-col space-y-1'>
<div className='flex py-5 text-sm justify-center mx-auto space-x-2 text-slate-500'>
<Download size={20} />
<div className='flex flex-col space-y-4 w-full px-2 sm:px-0'>
<div className='flex flex-col sm:flex-row py-5 sm:py-6 text-xs sm:text-sm justify-center mx-auto space-y-2 sm:space-y-0 sm:space-x-3 text-slate-600 px-4 sm:px-2 text-center sm:text-left bg-slate-50 rounded-lg border border-slate-100'>
<Download size={18} className="mx-auto sm:mx-0 text-slate-500" />
<p>Please download the file and grade it manually, then input the grade above</p>
</div>
{userSubmissions.fileUUID && !isLoading && assignmentTaskUUID && (
<Link
href={getTaskFileSubmissionDir(org?.org_uuid, assignment.course_object.course_uuid, assignment.activity_object.activity_uuid, assignment.assignment_object.assignment_uuid, assignmentTaskUUID, userSubmissions.fileUUID)}
target='_blank'
className='flex flex-col rounded-lg bg-white text-gray-400 shadow-lg nice-shadow px-5 py-3 space-y-1 items-center relative'>
<div className='absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 bg-green-500 rounded-full px-1.5 py-1.5 text-white flex justify-center items-center'>
<Cloud size={15} />
className='flex flex-col rounded-lg bg-white text-gray-500 shadow-sm hover:shadow-md transition-shadow border border-gray-100 px-4 sm:px-5 py-4 space-y-1 items-center relative w-full sm:w-auto mx-auto'>
<div className='absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 bg-emerald-500 rounded-full p-1.5 text-white flex justify-center items-center shadow-sm'>
<Cloud size={14} />
</div>
<div
className='flex space-x-2 mt-2'>
<File size={20} className='' />
<div className='font-semibold text-sm uppercase'>
<div className='flex space-x-2 mt-2 items-center'>
<File size={18} className="text-emerald-500" />
<div className='font-medium text-xs sm:text-sm uppercase break-all'>
{`${userSubmissions.fileUUID.slice(0, 8)}...${userSubmissions.fileUUID.slice(-4)}`}
</div>
</div>
@ -220,66 +241,72 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
)}
{view === 'student' && (
<>
<div className="w-auto bg-white rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow">
<div className="flex flex-col justify-center items-center h-full">
<div className="flex flex-col justify-center items-center">
<div className="flex flex-col justify-center items-center">
<div className="w-full bg-white rounded-lg border border-gray-100 min-h-[200px] shadow-sm px-4 sm:px-6 py-5 sm:py-6">
<div className="flex flex-col justify-center items-center h-full w-full">
<div className="flex flex-col justify-center items-center w-full max-w-full">
<div className="flex flex-col justify-center items-center w-full">
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-2 transition-all shadow-sm">
<div className="text-sm font-semibold">{error}</div>
<div className="flex justify-center bg-red-50 border border-red-100 rounded-md text-red-600 space-x-2 items-center p-3 transition-all shadow-sm w-full sm:w-auto mb-4">
<div className="text-xs sm:text-sm font-medium">{error}</div>
</div>
)}
</div>
{localUploadFile && !isLoading && (
<div className='flex flex-col rounded-lg bg-white text-gray-400 shadow-lg nice-shadow px-5 py-3 space-y-1 items-center relative'>
<div className='absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 bg-green-500 rounded-full px-1.5 py-1.5 text-white flex justify-center items-center'>
<Cloud size={15} />
<div className='flex flex-col rounded-lg bg-white text-gray-500 shadow-sm border border-gray-100 px-4 sm:px-5 py-4 space-y-1 items-center relative w-full sm:w-auto mt-3'>
<div className='absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 bg-emerald-500 rounded-full p-1.5 text-white flex justify-center items-center shadow-sm'>
<Cloud size={14} />
</div>
<div className='flex space-x-2 mt-2'>
<File size={20} className='' />
<div className='font-semibold text-sm uppercase'>
{localUploadFile.name}
<div className='flex space-x-2 mt-2 items-center'>
<File size={18} className="text-emerald-500" />
<div className='font-medium text-xs sm:text-sm uppercase break-all'>
{localUploadFile.name.length > 20
? `${localUploadFile.name.slice(0, 10)}...${localUploadFile.name.slice(-10)}`
: localUploadFile.name}
</div>
</div>
</div>
)}
{userSubmissions.fileUUID && !isLoading && !localUploadFile && (
<div className='flex flex-col rounded-lg bg-white text-gray-400 shadow-lg nice-shadow px-5 py-3 space-y-1 items-center relative'>
<div className='absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 bg-green-500 rounded-full px-1.5 py-1.5 text-white flex justify-center items-center'>
<Cloud size={15} />
<div className='flex flex-col rounded-lg bg-white text-gray-500 shadow-sm border border-gray-100 px-4 sm:px-5 py-4 space-y-1 items-center relative w-full sm:w-auto mt-3'>
<div className='absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 bg-emerald-500 rounded-full p-1.5 text-white flex justify-center items-center shadow-sm'>
<Cloud size={14} />
</div>
<div className='flex space-x-2 mt-2'>
<File size={20} className='' />
<div className='font-semibold text-sm uppercase'>
<div className='flex space-x-2 mt-2 items-center'>
<File size={18} className="text-emerald-500" />
<div className='font-medium text-xs sm:text-sm uppercase break-all'>
{`${userSubmissions.fileUUID.slice(0, 8)}...${userSubmissions.fileUUID.slice(-4)}`}
</div>
</div>
</div>
)}
<div className='flex pt-4 font-semibold space-x-1.5 text-xs items-center text-gray-500 '>
<Info size={16} />
<p>Allowed formats : pdf, docx, mp4, jpg, jpeg, png, pptx, zip</p>
<div className='flex flex-col sm:flex-row pt-5 font-medium space-y-1 sm:space-y-0 sm:space-x-2 text-xs items-center text-slate-500 text-center sm:text-left bg-slate-50 rounded-lg px-3 py-2 mt-5 border border-slate-100 w-full sm:w-auto'>
<Info size={15} className="mx-auto sm:mx-0 text-slate-400" />
<p>Allowed formats: pdf, docx, mp4, jpg, jpeg, png, pptx, zip</p>
</div>
{isLoading ? (
<div className="flex justify-center items-center">
{!access_token ? (
<div className="flex justify-center items-center w-full mt-5">
<div className="flex justify-center bg-amber-50 border border-amber-100 rounded-md text-amber-600 space-x-2 items-center p-3 transition-all shadow-sm w-full sm:w-auto">
<Info size={15} className="text-amber-500" />
<div className="text-xs sm:text-sm font-medium">Please sign in to upload files</div>
</div>
</div>
) : isLoading ? (
<div className="flex justify-center items-center w-full mt-5">
<input
type="file"
id="fileInput"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<div className="font-bold animate-pulse antialiased items-center bg-slate-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex">
<Loader size={16} className="mr-2" />
<div className="font-medium animate-pulse antialiased items-center bg-slate-100 text-slate-600 text-xs sm:text-sm rounded-md px-4 sm:px-5 py-2.5 flex">
<Loader size={15} className="mr-2" />
<span>Loading</span>
</div>
</div>
) : (
<div className="flex justify-center items-center">
<div className="flex justify-center items-center w-full mt-5">
<input
type="file"
id={"fileInput_" + assignmentTaskUUID}
@ -287,10 +314,10 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
onChange={handleFileChange}
/>
<button
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
className="font-medium antialiased items-center text-white text-xs sm:text-sm rounded-md px-4 sm:px-5 py-2.5 flex bg-emerald-500 hover:bg-emerald-600 transition-colors shadow-sm"
onClick={() => document.getElementById("fileInput_" + assignmentTaskUUID)?.click()}
>
<UploadCloud size={16} className="mr-2" />
<UploadCloud size={15} className="mr-2" />
<span>Submit File</span>
</button>
</div>

View file

@ -1,6 +1,7 @@
import { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext'
import { BookPlus, BookUser, EllipsisVertical, FileUp, Forward, InfoIcon, ListTodo, Save } from 'lucide-react'
import React, { useEffect } from 'react'
import { useLHSession } from '@components/Contexts/LHSessionContext'
type AssignmentBoxProps = {
type: 'quiz' | 'file'
@ -13,21 +14,25 @@ type AssignmentBoxProps = {
gradeCustomFC?: (grade: number) => void
showSavingDisclaimer?: boolean
children: React.ReactNode
}
function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitFC, gradeFC, gradeCustomFC, showSavingDisclaimer, children }: AssignmentBoxProps) {
const [customGrade, setCustomGrade] = React.useState<number>(0)
const submission = useAssignmentSubmission() as any
const session = useLHSession() as any
useEffect(() => {
console.log(submission)
}
, [submission])
}, [submission])
// Check if user is authenticated
const isAuthenticated = session?.status === 'authenticated'
return (
<div className='flex flex-col px-6 py-4 nice-shadow rounded-md bg-slate-100/30'>
<div className='flex justify-between space-x-2 pb-2 text-slate-400 items-center'>
<div className='flex space-x-1 items-center'>
<div className='flex flex-col px-3 sm:px-6 py-4 nice-shadow rounded-md bg-slate-100/30'>
<div className='flex flex-col sm:flex-row sm:justify-between sm:space-x-2 pb-2 text-slate-400 sm:items-center'>
{/* Left side with type and badges */}
<div className='flex flex-wrap gap-2 items-center mb-2 sm:mb-0'>
<div className='text-lg font-semibold'>
{type === 'quiz' &&
<div className='flex space-x-1.5 items-center'>
@ -41,26 +46,27 @@ function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitF
</div>}
</div>
<div className='flex items-center space-x-1'>
<EllipsisVertical size={15} />
</div>
{view === 'teacher' &&
<div className='flex bg-amber-200/20 text-xs rounded-full space-x-1 px-2 py-0.5 mx-auto font-bold outline items-center text-amber-600 outline-1 outline-amber-300/40'>
<div className='flex bg-amber-200/20 text-xs rounded-full space-x-1 px-2 py-0.5 font-bold outline items-center text-amber-600 outline-1 outline-amber-300/40'>
<BookUser size={12} />
<p>Teacher view</p>
</div>
}
{maxPoints &&
<div className='flex bg-emerald-200/20 text-xs rounded-full space-x-1 px-2 py-0.5 mx-auto font-bold outline items-center text-emerald-600 outline-1 outline-emerald-300/40'>
<div className='flex bg-emerald-200/20 text-xs rounded-full space-x-1 px-2 py-0.5 font-bold outline items-center text-emerald-600 outline-1 outline-emerald-300/40'>
<BookPlus size={12} />
<p>{maxPoints} points</p>
</div>
}
</div>
<div className='flex px-1 py-1 rounded-md items-center'>
{/* Right side with buttons and actions */}
<div className='flex flex-wrap gap-2 items-center'>
{showSavingDisclaimer &&
<div className='flex space-x-2 items-center font-semibold px-3 py-1 outline-dashed outline-red-200 text-red-400 mr-5 rounded-full'>
<div className='flex space-x-2 items-center font-semibold px-3 py-1 outline-dashed outline-red-200 text-red-400 sm:mr-5 rounded-full w-full sm:w-auto mb-2 sm:mb-0'>
<InfoIcon size={14} />
<p className='text-xs'>Don't forget to save your progress</p>
</div>
@ -70,17 +76,17 @@ function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitF
{view === 'teacher' &&
<div
onClick={() => saveFC && saveFC()}
className='flex px-2 py-1 cursor-pointer rounded-md space-x-2 items-center bg-gradient-to-bl text-emerald-700 bg-emerald-300/20 hover:bg-emerald-300/10 hover:outline-offset-4 active:outline-offset-1 linear transition-all outline-offset-2 outline-dashed outline-emerald-500/60'>
className='flex px-2 py-1 cursor-pointer rounded-md space-x-2 items-center bg-gradient-to-bl text-emerald-700 bg-emerald-300/20 hover:bg-emerald-300/10 hover:outline-offset-4 active:outline-offset-1 linear transition-all outline-offset-2 outline-dashed outline-emerald-500/60'>
<Save size={14} />
<p className='text-xs font-semibold'>Save</p>
</div>
}
{/* Student button */}
{view === 'student' && submission && submission.length <= 0 &&
{/* Student button - only show if authenticated */}
{view === 'student' && isAuthenticated && submission && submission.length <= 0 &&
<div
onClick={() => submitFC && submitFC()}
className='flex px-2 py-1 cursor-pointer rounded-md space-x-2 items-center bg-gradient-to-bl text-emerald-700 bg-emerald-300/20 hover:bg-emerald-300/10 hover:outline-offset-4 active:outline-offset-1 linear transition-all outline-offset-2 outline-dashed outline-emerald-500/60'>
className='flex px-2 py-1 cursor-pointer rounded-md space-x-2 items-center justify-center mx-auto w-full sm:w-auto bg-gradient-to-bl text-emerald-700 bg-emerald-300/20 hover:bg-emerald-300/10 hover:outline-offset-4 active:outline-offset-1 linear transition-all outline-offset-2 outline-dashed outline-emerald-500/60'>
<Forward size={14} />
<p className='text-xs font-semibold'>Save your progress</p>
</div>
@ -89,11 +95,11 @@ function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitF
{/* Grading button */}
{view === 'grading' &&
<div
className='flex px-0.5 py-0.5 cursor-pointer rounded-md space-x-2 items-center bg-gradient-to-bl hover:outline-offset-4 active:outline-offset-1 linear transition-all outline-offset-2 outline-dashed outline-orange-500/60'>
<p className='font-semibold px-2 text-xs text-orange-700'>Current points : {currentPoints}</p>
className='flex flex-wrap sm:flex-nowrap w-full sm:w-auto px-0.5 py-0.5 cursor-pointer rounded-md gap-2 sm:space-x-2 items-center bg-gradient-to-bl hover:outline-offset-4 active:outline-offset-1 linear transition-all outline-offset-2 outline-dashed outline-orange-500/60'>
<p className='font-semibold px-2 text-xs text-orange-700'>Current points: {currentPoints}</p>
<div
onClick={() => gradeFC && gradeFC()}
className='bg-gradient-to-bl text-orange-700 bg-orange-300/20 hover:bg-orange-300/10 items-center flex rounded-md px-2 py-1 space-x-2'>
className='bg-gradient-to-bl text-orange-700 bg-orange-300/20 hover:bg-orange-300/10 items-center flex rounded-md px-2 py-1 space-x-2 ml-auto'>
<BookPlus size={14} />
<p className='text-xs font-semibold'>Grade</p>
</div>
@ -103,16 +109,21 @@ function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitF
{/* CustomGrading button */}
{view === 'custom-grading' && maxPoints &&
<div
className='flex px-0.5 py-0.5 cursor-pointer rounded-md space-x-2 items-center bg-gradient-to-bl hover:outline-offset-4 active:outline-offset-1 linear transition-all outline-offset-2 outline-dashed outline-orange-500/60'>
<p className='font-semibold px-2 text-xs text-orange-700'>Current points : {currentPoints}</p>
<input
onChange={(e) => setCustomGrade(parseInt(e.target.value))}
placeholder={maxPoints.toString()} className='w-[100px] light-shadow text-sm py-0.5 outline outline-gray-200 rounded-lg px-2' type="number" />
<div
onClick={() => gradeCustomFC && gradeCustomFC(customGrade)}
className='bg-gradient-to-bl text-orange-700 bg-orange-300/20 hover:bg-orange-300/10 items-center flex rounded-md px-2 py-1 space-x-2'>
<BookPlus size={14} />
<p className='text-xs font-semibold'>Grade</p>
className='flex flex-wrap sm:flex-nowrap w-full sm:w-auto px-0.5 py-0.5 cursor-pointer rounded-md gap-2 sm:space-x-2 items-center bg-gradient-to-bl hover:outline-offset-4 active:outline-offset-1 linear transition-all outline-offset-2 outline-dashed outline-orange-500/60'>
<p className='font-semibold px-2 text-xs text-orange-700 w-full sm:w-auto'>Current points: {currentPoints}</p>
<div className='flex items-center gap-2 w-full sm:w-auto'>
<input
onChange={(e) => setCustomGrade(parseInt(e.target.value))}
placeholder={maxPoints.toString()}
className='w-full sm:w-[100px] light-shadow text-sm py-0.5 outline outline-gray-200 rounded-lg px-2'
type="number"
/>
<div
onClick={() => gradeCustomFC && gradeCustomFC(customGrade)}
className='bg-gradient-to-bl text-orange-700 bg-orange-300/20 hover:bg-orange-300/10 items-center flex rounded-md px-2 py-1 space-x-2 whitespace-nowrap'>
<BookPlus size={14} />
<p className='text-xs font-semibold'>Grade</p>
</div>
</div>
</div>
}

View file

@ -18,38 +18,39 @@ function AssignmentStudentActivity() {
return (
<div className='flex flex-col space-y-6'>
<div className='flex flex-row justify-center space-x-3 items-center '>
<div className='text-xs h-fit flex space-x-3 items-center '>
<div className='flex space-x-2 py-2 px-5 h-fit text-sm text-slate-700 bg-slate-100/5 rounded-full nice-shadow'>
<Backpack size={18} />
<div className='flex flex-col space-y-4 md:space-y-6'>
<div className='flex flex-col md:flex-row justify-center md:space-x-3 space-y-3 md:space-y-0 items-center'>
<div className='text-xs h-fit flex space-x-3 items-center'>
<div className='flex space-x-2 py-2 px-4 md:px-5 h-fit text-sm text-slate-700 bg-slate-100/5 rounded-full nice-shadow'>
<Backpack size={16} className="md:size-18" />
<p className='font-semibold'>Assignment</p>
</div>
</div>
<div>
<div className='flex space-x-2 items-center'>
<EllipsisVertical className='text-slate-400' size={18} />
<EllipsisVertical className='text-slate-400 hidden md:block' size={18} />
<div className='flex space-x-2 items-center'>
<div className='flex space-x-2 text-xs items-center text-slate-400'>
<div className='flex space-x-1 md:space-x-2 text-xs items-center text-slate-400'>
<Calendar size={14} />
<p className=' font-semibold'>Due Date</p>
<p className=' font-semibold'>{assignments?.assignment_object?.due_date}</p>
<p className='font-semibold'>Due Date</p>
<p className='font-semibold'>{assignments?.assignment_object?.due_date}</p>
</div>
</div>
</div>
</div>
</div>
<div className='w-full rounded-full bg-slate-500/5 nice-shadow h-[2px]'></div>
{assignments && assignments?.assignment_tasks?.sort((a: any, b: any) => a.id - b.id).map((task: any, index: number) => {
return (
<div className='flex flex-col space-y-2' key={task.assignment_task_uuid}>
<div className='flex justify-between py-2'>
<div className='flex space-x-2 font-semibold text-slate-800'>
<div className='flex flex-col md:flex-row md:justify-between py-2 space-y-2 md:space-y-0'>
<div className='flex flex-wrap space-x-2 font-semibold text-slate-800'>
<p>Task {index + 1} : </p>
<p className='text-slate-500'>{task.description}</p>
<p className='text-slate-500 break-words'>{task.description}</p>
</div>
<div className='flex space-x-2'>
<div className='flex flex-wrap gap-2'>
<div
onClick={() => alert(task.hint)}
className='px-3 py-1 flex items-center nice-shadow bg-amber-50/40 text-amber-900 rounded-full space-x-2 cursor-pointer'>
@ -67,9 +68,9 @@ function AssignmentStudentActivity() {
)}
target='_blank'
download={true}
className='px-3 py-1 flex items-center nice-shadow bg-cyan-50/40 text-cyan-900 rounded-full space-x-2 cursor-pointer'>
className='px-3 py-1 flex items-center nice-shadow bg-cyan-50/40 text-cyan-900 rounded-full space-x-1 md:space-x-2 cursor-pointer'>
<Download size={13} />
<div className='flex items-center space-x-2'>
<div className='flex items-center space-x-1 md:space-x-2'>
{task.reference_file && (
<span className='relative'>
<span className='absolute right-0 top-0 block h-2 w-2 rounded-full ring-2 ring-white bg-green-400'></span>
@ -80,7 +81,7 @@ function AssignmentStudentActivity() {
</Link>
</div>
</div>
<div>
<div className='w-full'>
{task.assignment_type === 'QUIZ' && <TaskQuizObject key={task.assignment_task_uuid} view='student' assignmentTaskUUID={task.assignment_task_uuid} />}
{task.assignment_type === 'FILE_SUBMISSION' && <TaskFileObject key={task.assignment_task_uuid} view='student' assignmentTaskUUID={task.assignment_task_uuid} />}
</div>

View file

@ -1,29 +1,71 @@
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
import { AlertCircle } from 'lucide-react'
import React from 'react'
import { AlertCircle, X } from 'lucide-react'
import React, { useState } from 'react'
import styled from 'styled-components'
function InfoCalloutComponent(props: any) {
const editorState = useEditorProvider() as any
const isEditable = editorState.isEditable
return (
<NodeViewWrapper>
<InfoCalloutWrapper
className="flex space-x-2 items-center bg-blue-200 rounded-lg text-blue-900 px-3 shadow-inner"
contentEditable={isEditable}
>
<AlertCircle />{' '}
<NodeViewContent contentEditable={isEditable} className="content" />
</InfoCalloutWrapper>
</NodeViewWrapper>
)
interface CalloutOptions {
dismissible?: boolean;
variant?: 'default' | 'filled' | 'outlined';
size?: 'sm' | 'md' | 'lg';
}
const InfoCalloutWrapper = styled.div`
const IconWrapper = styled.div<{ size?: string }>`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 0.5rem;
padding-left: 0.5rem;
svg {
padding: 3px;
width: 20px;
height: 20px;
min-width: 20px;
}
@media (max-width: 640px) {
margin-right: 0.25rem;
padding-left: 0.375rem;
padding-top: ${props => props.size === 'sm' ? '0' : '0.5rem'};
align-self: ${props => props.size === 'sm' ? 'center' : 'flex-start'};
}
`
const ContentWrapper = styled.div`
width: 100%;
overflow-wrap: break-word;
`
const DismissButton = styled.button`
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
margin-left: 8px;
border-radius: 50%;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`
const InfoCalloutWrapper = styled.div<{ size?: string }>`
width: 100%;
display: flex;
position: relative;
margin: 1rem 0;
@media (max-width: 640px) {
flex-direction: ${props => props.size === 'sm' ? 'row' : 'column'};
align-items: ${props => props.size === 'sm' ? 'center' : 'flex-start'};
}
svg {
padding: 0;
}
.content {
@ -32,7 +74,69 @@ const InfoCalloutWrapper = styled.div`
border: ${(props) =>
props.contentEditable ? '2px dashed #1f3a8a12' : 'none'};
border-radius: 0.5rem;
@media (max-width: 640px) {
margin: ${props => props.size === 'sm' ? '3px' : '5px 0'};
padding: ${props => props.size === 'sm' ? '0.25rem' : '0.5rem'};
width: 100%;
}
}
`
function InfoCalloutComponent(props: any) {
const editorState = useEditorProvider() as any
const isEditable = editorState.isEditable
const [dismissed, setDismissed] = useState(false)
// Extract options from props or use defaults
const options: CalloutOptions = {
dismissible: props.node?.attrs?.dismissible || false,
variant: props.node?.attrs?.variant || 'default',
size: props.node?.attrs?.size || 'md',
}
if (dismissed) return null;
const getVariantClasses = () => {
switch(options.variant) {
case 'filled':
return 'bg-blue-500 text-white';
case 'outlined':
return 'bg-transparent border-2 border-blue-500 text-blue-700';
default:
return 'bg-blue-200 text-blue-900';
}
}
const getSizeClasses = () => {
switch(options.size) {
case 'sm': return 'py-1 px-2 text-sm';
case 'lg': return 'py-3 px-4 text-lg';
default: return 'py-2 px-3';
}
}
return (
<NodeViewWrapper>
<InfoCalloutWrapper
className={`flex items-center rounded-lg shadow-inner ${getVariantClasses()} ${getSizeClasses()}`}
contentEditable={isEditable}
size={options.size}
>
<IconWrapper size={options.size}>
<AlertCircle />
</IconWrapper>
<ContentWrapper className="flex-grow">
<NodeViewContent contentEditable={isEditable} className="content" />
</ContentWrapper>
{options.dismissible && !isEditable && (
<DismissButton onClick={() => setDismissed(true)}>
<X size={16} />
</DismissButton>
)}
</InfoCalloutWrapper>
</NodeViewWrapper>
)
}
export default InfoCalloutComponent

View file

@ -1,29 +1,71 @@
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
import { AlertTriangle } from 'lucide-react'
import React from 'react'
import { AlertTriangle, X } from 'lucide-react'
import React, { useState } from 'react'
import styled from 'styled-components'
function WarningCalloutComponent(props: any) {
const editorState = useEditorProvider() as any
const isEditable = editorState.isEditable
return (
<NodeViewWrapper>
<CalloutWrapper
className="flex space-x-2 items-center bg-yellow-200 rounded-lg text-yellow-900 px-3 shadow-inner"
contentEditable={isEditable}
>
<AlertTriangle />{' '}
<NodeViewContent contentEditable={isEditable} className="content" />
</CalloutWrapper>
</NodeViewWrapper>
)
interface CalloutOptions {
dismissible?: boolean;
variant?: 'default' | 'filled' | 'outlined';
size?: 'sm' | 'md' | 'lg';
}
const CalloutWrapper = styled.div`
const IconWrapper = styled.div<{ size?: string }>`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 0.5rem;
padding-left: 0.5rem;
svg {
padding: 3px;
width: 20px;
height: 20px;
min-width: 20px;
}
@media (max-width: 640px) {
margin-right: 0.25rem;
padding-left: 0.375rem;
padding-top: ${props => props.size === 'sm' ? '0' : '0.5rem'};
align-self: ${props => props.size === 'sm' ? 'center' : 'flex-start'};
}
`
const ContentWrapper = styled.div`
width: 100%;
overflow-wrap: break-word;
`
const DismissButton = styled.button`
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
margin-left: 8px;
border-radius: 50%;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`
const CalloutWrapper = styled.div<{ size?: string }>`
width: 100%;
display: flex;
position: relative;
margin: 1rem 0;
@media (max-width: 640px) {
flex-direction: ${props => props.size === 'sm' ? 'row' : 'column'};
align-items: ${props => props.size === 'sm' ? 'center' : 'flex-start'};
}
svg {
padding: 0;
}
.content {
@ -32,17 +74,69 @@ const CalloutWrapper = styled.div`
border: ${(props) =>
props.contentEditable ? '2px dashed #713f1117' : 'none'};
border-radius: 0.5rem;
@media (max-width: 640px) {
margin: ${props => props.size === 'sm' ? '3px' : '5px 0'};
padding: ${props => props.size === 'sm' ? '0.25rem' : '0.5rem'};
width: 100%;
}
}
`
const DragHandle = styled.div`
position: absolute;
top: 0;
left: 0;
width: 1rem;
height: 100%;
cursor: move;
z-index: 1;
`
function WarningCalloutComponent(props: any) {
const editorState = useEditorProvider() as any
const isEditable = editorState.isEditable
const [dismissed, setDismissed] = useState(false)
// Extract options from props or use defaults
const options: CalloutOptions = {
dismissible: props.node?.attrs?.dismissible || false,
variant: props.node?.attrs?.variant || 'default',
size: props.node?.attrs?.size || 'md',
}
if (dismissed) return null;
const getVariantClasses = () => {
switch(options.variant) {
case 'filled':
return 'bg-yellow-500 text-white';
case 'outlined':
return 'bg-transparent border-2 border-yellow-500 text-yellow-700';
default:
return 'bg-yellow-200 text-yellow-900';
}
}
const getSizeClasses = () => {
switch(options.size) {
case 'sm': return 'py-1 px-2 text-sm';
case 'lg': return 'py-3 px-4 text-lg';
default: return 'py-2 px-3';
}
}
export default WarningCalloutComponent
return (
<NodeViewWrapper>
<CalloutWrapper
className={`flex items-center rounded-lg shadow-inner ${getVariantClasses()} ${getSizeClasses()}`}
contentEditable={isEditable}
size={options.size}
>
<IconWrapper size={options.size}>
<AlertTriangle />
</IconWrapper>
<ContentWrapper className="flex-grow">
<NodeViewContent contentEditable={isEditable} className="content" />
</ContentWrapper>
{options.dismissible && !isEditable && (
<DismissButton onClick={() => setDismissed(true)}>
<X size={16} />
</DismissButton>
)}
</CalloutWrapper>
</NodeViewWrapper>
)
}
export default WarningCalloutComponent

View file

@ -2,7 +2,7 @@ import { NodeViewWrapper } from '@tiptap/react'
import React, { useState, useRef, useEffect, useMemo } from 'react'
import { Upload, Link as LinkIcon, GripVertical, GripHorizontal, AlignCenter, Cuboid, Code } from 'lucide-react'
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
import { SiGithub, SiReplit, SiSpotify, SiLoom, SiGooglemaps, SiCodepen, SiCanva, SiNotion, SiGoogledocs, SiGitlab, SiX, SiFigma, SiGiphy } from '@icons-pack/react-simple-icons'
import { SiGithub, SiReplit, SiSpotify, SiLoom, SiGooglemaps, SiCodepen, SiCanva, SiNotion, SiGoogledocs, SiGitlab, SiX, SiFigma, SiGiphy, SiYoutube } from '@icons-pack/react-simple-icons'
import { useRouter } from 'next/navigation'
import DOMPurify from 'dompurify'
@ -14,6 +14,44 @@ const SCRIPT_BASED_EMBEDS = {
// Add more platforms as needed
};
// Helper function to convert YouTube URLs to embed format
const getYouTubeEmbedUrl = (url: string): string => {
try {
// First validate that this is a proper URL
const parsedUrl = new URL(url);
// Ensure the hostname is actually YouTube
const isYoutubeHostname =
parsedUrl.hostname === 'youtube.com' ||
parsedUrl.hostname === 'www.youtube.com' ||
parsedUrl.hostname === 'youtu.be' ||
parsedUrl.hostname === 'www.youtu.be';
if (!isYoutubeHostname) {
return url; // Not a YouTube URL, return as is
}
// Handle different YouTube URL formats with a more precise regex
const youtubeRegex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
const match = url.match(youtubeRegex);
if (match && match[1]) {
// Validate the video ID format (should be exactly 11 characters)
const videoId = match[1];
if (videoId.length === 11) {
// Return the embed URL with the video ID and secure protocol
return `https://www.youtube.com/embed/${videoId}?autoplay=0&rel=0`;
}
}
// If no valid match found, return the original URL
return url;
} catch (e) {
// If URL parsing fails, return the original URL
return url;
}
};
// Add new memoized component for the embed content
const MemoizedEmbed = React.memo(({ embedUrl, sanitizedEmbedCode, embedType }: {
embedUrl: string;
@ -43,9 +81,26 @@ const MemoizedEmbed = React.memo(({ embedUrl, sanitizedEmbedCode, embedType }: {
}, [embedType, sanitizedEmbedCode]);
if (embedType === 'url' && embedUrl) {
// Process the URL if it's a YouTube URL - using proper URL validation
let isYoutubeUrl = false;
try {
const url = new URL(embedUrl);
// Check if the hostname is exactly youtube.com or youtu.be (or www variants)
isYoutubeUrl = url.hostname === 'youtube.com' ||
url.hostname === 'www.youtube.com' ||
url.hostname === 'youtu.be' ||
url.hostname === 'www.youtu.be';
} catch (e) {
// Invalid URL format, not a YouTube URL
isYoutubeUrl = false;
}
const processedUrl = isYoutubeUrl ? getYouTubeEmbedUrl(embedUrl) : embedUrl;
return (
<iframe
src={embedUrl}
src={processedUrl}
className="w-full h-full"
frameBorder="0"
allowFullScreen
@ -69,13 +124,61 @@ function EmbedObjectsComponent(props: any) {
const [embedWidth, setEmbedWidth] = useState(props.node.attrs.embedWidth || '100%')
const [alignment, setAlignment] = useState(props.node.attrs.alignment || 'left')
const [isResizing, setIsResizing] = useState(false)
const [parentWidth, setParentWidth] = useState<number | null>(null)
const [isMobile, setIsMobile] = useState(false)
const resizeRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const editorState = useEditorProvider() as any
const isEditable = editorState.isEditable
const router = useRouter()
// Add ResizeObserver to track parent container size changes
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current && containerRef.current.parentElement) {
const parentElement = containerRef.current.parentElement;
const newParentWidth = parentElement.offsetWidth;
setParentWidth(newParentWidth);
// Check if we're in a mobile viewport
setIsMobile(newParentWidth < 640); // 640px is a common breakpoint for small screens
// If embedWidth is set to a percentage, maintain that percentage
// Otherwise, adjust to fit parent width
if (typeof embedWidth === 'string' && embedWidth.endsWith('%')) {
const percentage = parseInt(embedWidth, 10);
const newWidth = `${Math.min(100, percentage)}%`;
setEmbedWidth(newWidth);
props.updateAttributes({ embedWidth: newWidth });
} else if (newParentWidth < parseInt(String(embedWidth), 10)) {
// If parent is smaller than current width, adjust to fit
setEmbedWidth('100%');
props.updateAttributes({ embedWidth: '100%' });
}
}
};
// Initialize dimensions
updateDimensions();
// Set up ResizeObserver
const resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
if (containerRef.current && containerRef.current.parentElement) {
resizeObserver.observe(containerRef.current.parentElement);
}
// Clean up
return () => {
resizeObserver.disconnect();
};
}, []);
const supportedProducts = [
{ name: 'YouTube', icon: SiYoutube, color: '#FF0000', guide: 'https://support.google.com/youtube/answer/171780?hl=en' },
{ name: 'GitHub', icon: SiGithub, color: '#181717', guide: 'https://emgithub.com/' },
{ name: 'Replit', icon: SiReplit, color: '#F26207', guide: 'https://docs.replit.com/hosting/embedding-repls' },
{ name: 'Spotify', icon: SiSpotify, color: '#1DB954', guide: 'https://developer.spotify.com/documentation/embeds' },
@ -110,12 +213,38 @@ function EmbedObjectsComponent(props: any) {
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newUrl = event.target.value;
const trimmedUrl = newUrl.trim();
// Only update if URL is not just whitespace
if (newUrl === '' || trimmedUrl) {
// First sanitize with DOMPurify
const sanitizedUrl = DOMPurify.sanitize(newUrl);
setEmbedUrl(sanitizedUrl);
// Additional URL validation for security
let validatedUrl = sanitizedUrl;
if (sanitizedUrl) {
try {
// Ensure it's a valid URL by parsing it
const url = new URL(sanitizedUrl);
// Only allow http and https protocols
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
// If invalid protocol, default to https
url.protocol = 'https:';
validatedUrl = url.toString();
}
} catch (e) {
// If it's not a valid URL, prepend https:// to make it valid
// Only do this if it's not empty and doesn't already start with a protocol
if (sanitizedUrl && !sanitizedUrl.match(/^[a-zA-Z]+:\/\//)) {
validatedUrl = `https://${sanitizedUrl}`;
}
}
}
setEmbedUrl(validatedUrl);
props.updateAttributes({
embedUrl: sanitizedUrl,
embedUrl: validatedUrl,
embedType: 'url',
});
}
@ -196,6 +325,30 @@ function EmbedObjectsComponent(props: any) {
window.open(guide, '_blank', 'noopener,noreferrer')
}
// Calculate responsive styles based on parent width
const getResponsiveStyles = () => {
// Default styles
const styles: React.CSSProperties = {
height: `${embedHeight}px`,
width: embedWidth,
};
// If parent width is available, ensure we don't exceed it
if (parentWidth) {
// For mobile viewports, always use 100% width
if (isMobile) {
styles.width = '100%';
styles.minWidth = 'unset';
} else {
// For desktop, use the set width but ensure it's not wider than parent
styles.minWidth = Math.min(parentWidth, 400) + 'px';
styles.maxWidth = '100%';
}
}
return styles;
};
// Memoize the embed content
const embedContent = useMemo(() => (
!isResizing && (embedUrl || sanitizedEmbedCode) ? (
@ -209,73 +362,280 @@ function EmbedObjectsComponent(props: any) {
)
), [embedUrl, sanitizedEmbedCode, embedType, isResizing]);
// Input states
const [activeInput, setActiveInput] = useState<'none' | 'url' | 'code'>('none');
const [selectedProduct, setSelectedProduct] = useState<typeof supportedProducts[0] | null>(null);
const urlInputRef = useRef<HTMLInputElement>(null);
const codeInputRef = useRef<HTMLTextAreaElement>(null);
// Handle direct input from product selection
const handleProductSelection = (product: typeof supportedProducts[0]) => {
// Set the input type to URL by default
setEmbedType('url');
setActiveInput('url');
// Store the selected product for the popup
setSelectedProduct(product);
// Focus the URL input after a short delay to allow rendering
setTimeout(() => {
if (urlInputRef.current) {
urlInputRef.current.focus();
}
}, 50);
};
// Handle input submission
const handleInputSubmit = (e: React.FormEvent) => {
e.preventDefault();
setActiveInput('none');
};
// Handle escape key to cancel input
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setActiveInput('none');
}
};
// Handle opening documentation
const handleOpenDocs = (guide: string) => {
window.open(guide, '_blank', 'noopener,noreferrer');
};
return (
<NodeViewWrapper className="embed-block">
<NodeViewWrapper className="embed-block w-full" ref={containerRef}>
<div
ref={resizeRef}
className={`relative bg-gray-100 rounded-lg overflow-hidden flex justify-center items-center ${alignment === 'center' ? 'mx-auto' : ''}`}
style={{ height: `${embedHeight}px`, width: embedWidth, minWidth: '400px' }}
style={getResponsiveStyles()}
>
{(embedUrl || sanitizedEmbedCode) ? embedContent : (
<div className="w-full h-full flex flex-col items-center justify-center p-6">
<p className="text-gray-500 mb-4 font-medium tracking-tighter text-lg">Add an embed from :</p>
<div className="flex flex-wrap gap-5 justify-center">
{(embedUrl || sanitizedEmbedCode) ? (
// Show the embed content if we have a URL or code
<>
{embedContent}
{/* Minimal toolbar for existing embeds */}
{isEditable && (
<div className="absolute top-2 right-2 flex items-center gap-1.5 bg-white bg-opacity-90 backdrop-blur-sm rounded-lg p-1 shadow-sm transition-opacity opacity-70 hover:opacity-100">
<button
onClick={() => setActiveInput(embedType)}
className="p-1.5 rounded-md hover:bg-gray-100 text-gray-600"
title="Edit embed"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z"></path>
</svg>
</button>
<button
onClick={handleCenterBlock}
className="p-1.5 rounded-md hover:bg-gray-100 text-gray-600"
title={alignment === 'center' ? 'Align left' : 'Center align'}
>
<AlignCenter size={16} />
</button>
<button
onClick={() => {
setEmbedUrl('');
setEmbedCode('');
props.updateAttributes({
embedUrl: '',
embedCode: ''
});
}}
className="p-1.5 rounded-md hover:bg-gray-100 text-gray-600"
title="Remove embed"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
</button>
</div>
)}
</>
) : (
// Show the embed selection UI if we don't have content yet
<div className="w-full h-full flex flex-col items-center justify-center p-2 sm:p-6">
<p className="text-gray-500 mb-2 sm:mb-4 font-medium tracking-tighter text-base sm:text-lg text-center">Add an embed from :</p>
<div className="flex flex-wrap gap-2 sm:gap-5 justify-center">
{supportedProducts.map((product) => (
<button
key={product.name}
className="flex flex-col items-center group transition-transform hover:scale-110"
onClick={() => handleProductClick(product.guide)}
onClick={() => handleProductSelection(product)}
title={`Add ${product.name} embed`}
>
<div className="w-12 h-12 rounded-lg flex items-center justify-center shadow-md group-hover:shadow-lg transition-shadow" style={{ backgroundColor: product.color }}>
<product.icon size={24} color="#FFFFFF" />
<div className="w-8 h-8 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-md group-hover:shadow-lg transition-shadow" style={{ backgroundColor: product.color }}>
<product.icon size={isMobile ? 16 : 24} color="#FFFFFF" />
</div>
<span className="text-xs mt-2 text-gray-700 group-hover:text-gray-900 font-medium">{product.name}</span>
<span className="text-xs mt-1 sm:mt-2 text-gray-700 group-hover:text-gray-900 font-medium">{product.name}</span>
</button>
))}
</div>
<p className="text-xs text-gray-500 mt-3 mb-2 text-center max-w-md">
Click a service to add an embed
</p>
{/* Direct input options */}
{isEditable && (
<div className="mt-4 flex gap-3 justify-center">
<button
onClick={() => {
setEmbedType('url');
setActiveInput('url');
}}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white rounded-lg shadow-sm hover:shadow-md transition-all text-sm text-gray-700"
>
<LinkIcon size={14} />
<span>URL</span>
</button>
<button
onClick={() => {
setEmbedType('code');
setActiveInput('code');
}}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white rounded-lg shadow-sm hover:shadow-md transition-all text-sm text-gray-700"
>
<Code size={14} />
<span>Code</span>
</button>
</div>
)}
</div>
)}
<div className="absolute top-2 left-2 p-1 bg-white bg-opacity-70 rounded-md">
<Cuboid size={16} className="text-gray-600" />
</div>
{/* Inline input UI - appears in place without covering content */}
{isEditable && activeInput !== 'none' && (
<div className="absolute inset-0 bg-gray-100 bg-opacity-95 backdrop-blur-sm flex items-center justify-center p-4 z-10">
<form
onSubmit={handleInputSubmit}
className="w-full max-w-lg bg-white rounded-xl shadow-lg p-4"
onKeyDown={handleKeyDown}
>
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-2">
{selectedProduct && activeInput === 'url' && (
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ backgroundColor: selectedProduct.color }}
>
<selectedProduct.icon size={18} color="#FFFFFF" />
</div>
)}
<h3 className="text-lg font-medium text-gray-800">
{activeInput === 'url'
? (selectedProduct ? `Add ${selectedProduct.name} Embed` : 'Add Embed URL')
: 'Add Embed Code'}
</h3>
</div>
<button
type="button"
onClick={() => setActiveInput('none')}
className="p-1 rounded-full hover:bg-gray-100 text-gray-500"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{activeInput === 'url' ? (
<>
<div className="relative mb-2">
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-blue-500">
<LinkIcon size={16} />
</div>
<input
ref={urlInputRef}
type="text"
value={embedUrl}
onChange={handleUrlChange}
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:outline-none transition-all"
placeholder={selectedProduct ? `Paste ${selectedProduct.name} embed URL` : "Paste embed URL (YouTube, Spotify, etc.)"}
autoFocus
/>
</div>
<div className="flex justify-between items-center mb-4">
<p className="text-xs text-gray-500">
Tip: Paste any {selectedProduct?.name || "YouTube, Spotify, or other"} embed URL directly
</p>
{selectedProduct && (
<button
type="button"
onClick={() => handleOpenDocs(selectedProduct.guide)}
className="text-xs text-blue-500 hover:text-blue-700 flex items-center gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
How to embed {selectedProduct.name}
</button>
)}
</div>
</>
) : (
<>
<div className="relative mb-2">
<textarea
ref={codeInputRef}
value={embedCode}
onChange={handleCodeChange}
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl h-32 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:outline-none transition-all font-mono text-sm"
placeholder="Paste embed code (iframe, embed script, etc.)"
autoFocus
/>
</div>
<div className="flex justify-between items-center mb-4">
<p className="text-xs text-gray-500">
Tip: Paste iframe or embed code from any platform
</p>
{selectedProduct && (
<button
type="button"
onClick={() => handleOpenDocs(selectedProduct.guide)}
className="text-xs text-blue-500 hover:text-blue-700 flex items-center gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
How to embed {selectedProduct.name}
</button>
)}
</div>
</>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setActiveInput('none')}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 rounded-lg"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors"
disabled={(activeInput === 'url' && !embedUrl) || (activeInput === 'code' && !embedCode)}
>
Apply
</button>
</div>
</form>
</div>
)}
{/* Resize handles */}
{isEditable && (
<>
<div className="absolute bottom-2 left-2 flex gap-2">
<button
onClick={() => handleEmbedTypeChange('url')}
className={`p-2 rounded-md transition-colors ${embedType === 'url' ? 'bg-blue-500 text-white' : 'bg-white bg-opacity-70 text-gray-600'}`}
>
<LinkIcon size={16} />
</button>
<button
onClick={() => handleEmbedTypeChange('code')}
className={`p-2 rounded-md transition-colors ${embedType === 'code' ? 'bg-blue-500 text-white' : 'bg-white bg-opacity-70 text-gray-600'}`}
>
<Code size={16} />
</button>
{embedType === 'url' ? (
<input
type="text"
value={embedUrl}
onChange={handleUrlChange}
className="p-2 bg-white bg-opacity-70 rounded-md w-64"
placeholder="Enter embed URL"
/>
) : (
<textarea
value={embedCode}
onChange={handleCodeChange}
className="p-2 bg-white bg-opacity-70 rounded-md w-64 h-20"
placeholder="Enter embed code"
/>
)}
</div>
<button
onClick={handleCenterBlock}
className="absolute bottom-2 right-2 p-2 bg-white bg-opacity-70 rounded-md hover:bg-opacity-100 transition-opacity"
>
<AlignCenter size={16} className="text-gray-600" />
</button>
<div
className="absolute right-0 top-0 bottom-0 w-4 cursor-ew-resize flex items-center justify-center bg-white bg-opacity-70 hover:bg-opacity-100 transition-opacity"
onMouseDown={(e) => handleResizeStart(e, 'horizontal')}

View file

@ -18,7 +18,7 @@ function ImageBlockComponent(props: any) {
const course = useCourse() as any
const editorState = useEditorProvider() as any
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token;
const access_token = session?.data?.tokens?.access_token;
const isEditable = editorState.isEditable
const [image, setImage] = React.useState(null)
@ -29,6 +29,7 @@ function ImageBlockComponent(props: any) {
const [imageSize, setImageSize] = React.useState({
width: props.node.attrs.size ? props.node.attrs.size.width : 300,
})
const fileId = blockObject
? `${blockObject.content.file_id}.${blockObject.content.file_format}`
: null
@ -54,48 +55,70 @@ function ImageBlockComponent(props: any) {
useEffect(() => {}, [course, org])
return (
<NodeViewWrapper className="block-image">
<NodeViewWrapper className="block-image w-full">
<FileUploadBlock isEditable={isEditable} isLoading={isLoading} isEmpty={!blockObject} Icon={Image}>
<FileUploadBlockInput onChange={handleImageChange} accept={SUPPORTED_FILES} />
<FileUploadBlockButton onClick={handleSubmit} disabled={!image}/>
</FileUploadBlock>
{blockObject && (
<Resizable
defaultSize={{ width: imageSize.width, height: '100%' }}
handleStyles={{
right: {
position: 'unset',
width: 7,
height: 30,
borderRadius: 20,
cursor: 'col-resize',
backgroundColor: 'black',
opacity: '0.3',
margin: 'auto',
marginLeft: 5,
},
}}
style={{
margin: 'auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
}}
maxWidth={1000}
minWidth={200}
onResizeStop={(e, direction, ref, d) => {
props.updateAttributes({
size: {
width: imageSize.width + d.width,
{blockObject && isEditable && (
<div className="w-full flex justify-center">
<Resizable
defaultSize={{ width: imageSize.width, height: '100%' }}
handleStyles={{
right: {
position: 'unset',
width: 7,
height: 30,
borderRadius: 20,
cursor: 'col-resize',
backgroundColor: 'black',
opacity: '0.3',
margin: 'auto',
marginLeft: 5,
},
})
setImageSize({
width: imageSize.width + d.width,
})
}}
>
}}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
maxWidth: '100%',
}}
maxWidth="100%"
minWidth={200}
enable={{ right: true }}
onResizeStop={(e, direction, ref, d) => {
const newWidth = Math.min(imageSize.width + d.width, ref.parentElement?.clientWidth || 1000);
props.updateAttributes({
size: {
width: newWidth,
},
})
setImageSize({
width: newWidth,
})
}}
>
<img
src={`${getActivityBlockMediaDirectory(
org?.org_uuid,
course?.courseStructure.course_uuid,
props.extension.options.activity.activity_uuid,
blockObject.block_uuid,
blockObject ? fileId : ' ',
'imageBlock'
)}`}
alt=""
className="rounded-lg shadow max-w-full h-auto"
style={{ width: '100%' }}
/>
</Resizable>
</div>
)}
{blockObject && !isEditable && (
<div className="w-full flex justify-center">
<img
src={`${getActivityBlockMediaDirectory(
org?.org_uuid,
@ -106,10 +129,12 @@ function ImageBlockComponent(props: any) {
'imageBlock'
)}`}
alt=""
className="rounded-lg shadow "
className="rounded-lg shadow max-w-full h-auto"
style={{ width: imageSize.width, maxWidth: '100%' }}
/>
</Resizable>
</div>
)}
{isLoading && (
<div>
<AlertTriangle color="#e1e0e0" size={50} />

View file

@ -3,20 +3,241 @@ import React from 'react'
import styled from 'styled-components'
import 'katex/dist/katex.min.css'
import { BlockMath } from 'react-katex'
import { Save } from 'lucide-react'
import { Save, Sigma, ExternalLink, ChevronDown, BookOpen, Lightbulb } from 'lucide-react'
import Link from 'next/link'
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
import { motion } from 'framer-motion'
// Predefined LaTeX templates
const mathTemplates = [
{
name: 'Fraction',
latex: '\\frac{a}{b}',
description: 'Simple fraction'
},
{
name: 'Square Root',
latex: '\\sqrt{x}',
description: 'Square root'
},
{
name: 'Summation',
latex: '\\sum_{i=1}^{n} x_i',
description: 'Sum with limits'
},
{
name: 'Integral',
latex: '\\int_{a}^{b} f(x) \\, dx',
description: 'Definite integral'
},
{
name: 'Limit',
latex: '\\lim_{x \\to \\infty} f(x)',
description: 'Limit expression'
},
{
name: 'Matrix 2×2',
latex: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}',
description: '2×2 matrix with parentheses'
},
{
name: 'Binomial',
latex: '\\binom{n}{k}',
description: 'Binomial coefficient'
},
{
name: 'Quadratic Formula',
latex: 'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}',
description: 'Solution to quadratic equation'
},
{
name: 'Vector',
latex: '\\vec{v} = \\begin{pmatrix} x \\\\ y \\\\ z \\end{pmatrix}',
description: '3D vector'
},
{
name: 'System of Equations',
latex: '\\begin{cases} a_1x + b_1y = c_1 \\\\ a_2x + b_2y = c_2 \\end{cases}',
description: 'System of linear equations'
}
];
// Common LaTeX symbols
const mathSymbols = [
{ symbol: '\\alpha', display: 'α' },
{ symbol: '\\beta', display: 'β' },
{ symbol: '\\gamma', display: 'γ' },
{ symbol: '\\delta', display: 'δ' },
{ symbol: '\\theta', display: 'θ' },
{ symbol: '\\pi', display: 'π' },
{ symbol: '\\sigma', display: 'σ' },
{ symbol: '\\infty', display: '∞' },
{ symbol: '\\pm', display: '±' },
{ symbol: '\\div', display: '÷' },
{ symbol: '\\cdot', display: '·' },
{ symbol: '\\leq', display: '≤' },
{ symbol: '\\geq', display: '≥' },
{ symbol: '\\neq', display: '≠' },
{ symbol: '\\approx', display: '≈' },
];
// Styled components
const MathEqWrapper = styled.div`
transition: all 0.2s ease;
background-color: #f9f9f9;
border: 1px solid #eaeaea;
`
const EditBar = styled.div`
display: flex;
justify-content: space-between;
border-radius: 8px;
padding: 0 5px 0 12px;
background-color: white;
color: #5252528d;
align-items: center;
height: 45px;
border: solid 1px #e2e2e2;
transition: all 0.2s ease;
&:focus-within {
border-color: #d1d1d1;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.03);
}
input {
border: none;
background: none;
font-size: 14px;
color: #494949;
width: 100%;
font-family: 'DM Sans', sans-serif;
&:focus {
outline: none;
}
&::placeholder {
color: #49494980;
}
}
`
const SaveButton = styled(motion.button)`
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
border: none;
background: rgba(217, 217, 217, 0.5);
color: #494949;
cursor: pointer;
`
const InfoLink = styled.div`
padding-left: 2px;
`
const TemplateButton = styled.button`
display: flex;
align-items: center;
padding: 6px 10px;
background: rgba(217, 217, 217, 0.4);
border-radius: 6px;
border: none;
font-size: 13px;
color: #494949;
cursor: pointer;
`
const TemplateDropdown = styled.div`
background: white;
border-radius: 8px;
border: 1px solid #e2e2e2;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
`
const TemplateItem = styled.div`
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(217, 217, 217, 0.24);
}
`
const SymbolsDropdown = styled.div`
background: white;
border-radius: 8px;
border: 1px solid #e2e2e2;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
`
const SymbolButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 2px;
background: rgba(217, 217, 217, 0.3);
border-radius: 4px;
border: none;
font-size: 16px;
color: #494949;
cursor: pointer;
`
const HelpDropdown = styled.div`
background: white;
border-radius: 8px;
border: 1px solid #e2e2e2;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
`
function MathEquationBlockComponent(props: any) {
const [equation, setEquation] = React.useState(props.node.attrs.math_equation)
const [isEditing, setIsEditing] = React.useState(true)
const [showTemplates, setShowTemplates] = React.useState(false)
const [showSymbols, setShowSymbols] = React.useState(false)
const [showHelp, setShowHelp] = React.useState(false)
const editorState = useEditorProvider() as any
const isEditable = editorState.isEditable
const inputRef = React.useRef<HTMLInputElement>(null)
const templatesRef = React.useRef<HTMLDivElement>(null)
const symbolsRef = React.useRef<HTMLDivElement>(null)
const helpRef = React.useRef<HTMLDivElement>(null)
const handleEquationChange = (event: React.ChangeEvent<any>) => {
// Close dropdowns when clicking outside
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (templatesRef.current && !templatesRef.current.contains(event.target as Node)) {
setShowTemplates(false)
}
if (symbolsRef.current && !symbolsRef.current.contains(event.target as Node)) {
setShowSymbols(false)
}
if (helpRef.current && !helpRef.current.contains(event.target as Node)) {
setShowHelp(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const handleEquationChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEquation(event.target.value)
props.updateAttributes({
math_equation: equation,
math_equation: event.target.value,
})
}
@ -27,83 +248,209 @@ function MathEquationBlockComponent(props: any) {
//setIsEditing(false);
}
const insertTemplate = (template: string) => {
setEquation(template)
props.updateAttributes({
math_equation: template,
})
setShowTemplates(false)
// Focus the input and place cursor at the end
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.setSelectionRange(template.length, template.length)
}
}
const insertSymbol = (symbol: string) => {
const cursorPosition = inputRef.current?.selectionStart || equation.length
const newEquation = equation.substring(0, cursorPosition) + symbol + equation.substring(cursorPosition)
setEquation(newEquation)
props.updateAttributes({
math_equation: newEquation,
})
// Focus the input and place cursor after the inserted symbol
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.setSelectionRange(cursorPosition + symbol.length, cursorPosition + symbol.length)
}
}, 0)
}
return (
<NodeViewWrapper className="block-math-equation">
<MathEqWrapper className="flex flex-col space-y-2 bg-gray-50 shadow-inner rounded-lg py-7 px-5">
<BlockMath>{equation}</BlockMath>
{isEditing && isEditable && (
<>
<EditBar>
<input
value={equation}
onChange={handleEquationChange}
placeholder="Insert a Math Equation (LaTeX) "
type="text"
/>
<button className="opacity-1" onClick={() => saveEquation()}>
<Save size={15}></Save>
</button>
</EditBar>
<span className="pt-2 text-zinc-500 text-sm">
Please refer to this{' '}
<Link
className="text-zinc-900 after:content-['↗']"
href="https://katex.org/docs/supported.html"
target="_blank"
>
{' '}
guide
</Link>{' '}
for supported TeX functions{' '}
</span>
</>
)}
</MathEqWrapper>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<MathEqWrapper className="flex flex-col space-y-3 rounded-lg py-6 px-5">
<div className="flex items-center space-x-2 text-sm text-zinc-500 mb-1">
<Sigma size={16} />
<span className="font-medium">Math Equation</span>
</div>
<div className="bg-white p-4 rounded-md nice-shadow">
<BlockMath>{equation}</BlockMath>
</div>
{isEditing && isEditable && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ duration: 0.2 }}
className="space-y-3"
>
<div className="flex space-x-2">
<div ref={templatesRef} className="relative">
<TemplateButton
onClick={() => setShowTemplates(!showTemplates)}
className="flex items-center space-x-1"
>
<BookOpen size={14} />
<span>Templates</span>
<ChevronDown size={14} className={`transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
</TemplateButton>
{showTemplates && (
<TemplateDropdown className="absolute left-0 mt-1 z-10 w-64 max-h-80 overflow-y-auto">
<div className="p-2 text-xs text-zinc-500 border-b">
Select a template to insert
</div>
{mathTemplates.map((template, index) => (
<TemplateItem
key={index}
onClick={() => insertTemplate(template.latex)}
>
<div className="flex flex-col">
<span className="font-medium">{template.name}</span>
<span className="text-xs text-zinc-500">{template.description}</span>
</div>
</TemplateItem>
))}
</TemplateDropdown>
)}
</div>
<div ref={symbolsRef} className="relative">
<TemplateButton
onClick={() => setShowSymbols(!showSymbols)}
className="flex items-center space-x-1"
>
<Sigma size={14} />
<span>Symbols</span>
<ChevronDown size={14} className={`transition-transform ${showSymbols ? 'rotate-180' : ''}`} />
</TemplateButton>
{showSymbols && (
<SymbolsDropdown className="absolute left-0 mt-1 z-10 w-64">
<div className="p-2 text-xs text-zinc-500 border-b">
Click a symbol to insert
</div>
<div className="flex flex-wrap p-2">
{mathSymbols.map((symbol, index) => (
<SymbolButton
key={index}
onClick={() => insertSymbol(symbol.symbol)}
title={symbol.symbol}
>
{symbol.display}
</SymbolButton>
))}
</div>
</SymbolsDropdown>
)}
</div>
<div ref={helpRef} className="relative">
<TemplateButton
onClick={() => setShowHelp(!showHelp)}
className="flex items-center space-x-1"
>
<Lightbulb size={14} />
<span>Help</span>
<ChevronDown size={14} className={`transition-transform ${showHelp ? 'rotate-180' : ''}`} />
</TemplateButton>
{showHelp && (
<HelpDropdown className="absolute left-0 mt-1 z-10 w-72">
<div className="p-2 text-xs font-medium text-zinc-700 border-b">
LaTeX Math Quick Reference
</div>
<div className="p-3 text-xs space-y-2">
<div>
<span className="font-medium">Fractions:</span> \frac{'{'}'numerator'{'}'}{'{'}denominator{'}'}
</div>
<div>
<span className="font-medium">Exponents:</span> x^{'{'}'power'{'}'}
</div>
<div>
<span className="font-medium">Subscripts:</span> x_{'{'}'subscript'{'}'}
</div>
<div>
<span className="font-medium">Square root:</span> \sqrt{'{'}'x'{'}'}
</div>
<div>
<span className="font-medium">Summation:</span> \sum_{'{'}'lower'{'}'}^{'{'}'upper'{'}'}
</div>
<div>
<span className="font-medium">Integral:</span> \int_{'{'}'lower'{'}'}^{'{'}'upper'{'}'}
</div>
<div className="pt-1 border-t">
<Link
className="text-blue-600 hover:text-blue-800 font-medium flex items-center"
href="https://katex.org/docs/supported.html"
target="_blank"
>
View complete reference
<ExternalLink size={10} className="ml-1" />
</Link>
</div>
</div>
</HelpDropdown>
)}
</div>
</div>
<EditBar>
<input
ref={inputRef}
value={equation}
onChange={handleEquationChange}
placeholder="Insert a Math Equation (LaTeX)"
type="text"
className="focus:ring-1 focus:ring-blue-300"
/>
<SaveButton
onClick={() => saveEquation()}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Save size={15} />
</SaveButton>
</EditBar>
<InfoLink className="flex items-center text-zinc-500 text-sm">
<span>Please refer to this</span>
<Link
className="inline-flex items-center mx-1 text-blue-600 hover:text-blue-800 font-medium"
href="https://katex.org/docs/supported.html"
target="_blank"
>
guide
<ExternalLink size={12} className="ml-1" />
</Link>
<span>for supported TeX functions</span>
</InfoLink>
</motion.div>
)}
</MathEqWrapper>
</motion.div>
</NodeViewWrapper>
)
}
export default MathEquationBlockComponent
const MathEqWrapper = styled.div``
const EditBar = styled.div`
display: flex;
justify-content: flex-end;
margin-top: 10px;
background-color: white;
border-radius: 10px;
padding: 5px;
color: #5252528d;
align-items: center;
justify-content: space-between;
height: 50px;
border: solid 1px #52525224;
button {
margin-left: 10px;
margin-right: 7px;
cursor: pointer;
border: none;
background: none;
font-size: 14px;
color: #494949;
}
input {
border: none;
background: none;
font-size: 14px;
color: #494949;
width: 100%;
font-family: 'DM Sans', sans-serif;
padding-left: 10px;
&:focus {
outline: none;
}
&::placeholder {
color: #49494936;
}
}
`

View file

@ -50,6 +50,7 @@ function QuizBlockComponent(props: any) {
const refreshUserSubmission = () => {
setUserAnswers([])
setSubmitted(false)
setSubmissionMessage('')
}
const handleUserSubmission = () => {
@ -124,7 +125,7 @@ function QuizBlockComponent(props: any) {
correct: false,
}
// check if there is already more thqn 5 answers
// check if there is already more than 5 answers
const question: any = questions.find(
(question: Question) => question.question_id === question_id
)
@ -206,11 +207,11 @@ function QuizBlockComponent(props: any) {
return (
<NodeViewWrapper className="block-quiz">
<div
//style={{ background: "radial-gradient(152.15% 150.08% at 56.45% -6.67%, rgba(180, 255, 250, 0.10) 5.53%, rgba(202, 201, 255, 0.10) 66.76%)" }}
className="rounded-xl px-5 py-2 bg-slate-100 transition-all ease-linear"
className="rounded-xl px-3 sm:px-5 py-2 bg-slate-100 transition-all ease-linear"
>
<div className="flex space-x-2 pt-1 items-center text-sm overflow-hidden">
{submitted && submissionMessage == 'All answers are correct!' && (
{/* Header section */}
<div className="flex flex-wrap gap-2 pt-1 items-center text-sm">
{submitted && submissionMessage === 'All answers are correct!' && (
<ReactConfetti
numberOfPieces={submitted ? 1400 : 0}
recycle={false}
@ -223,7 +224,21 @@ function QuizBlockComponent(props: any) {
Quiz
</p>
</div>
<div className="grow flex items-center justify-center"></div>
{/* Submission message */}
{submitted && (
<div className={`text-xs font-medium px-2 py-1 rounded-md ${
submissionMessage === 'All answers are correct!'
? 'bg-lime-100 text-lime-700'
: 'bg-red-100 text-red-700'
}`}>
{submissionMessage}
</div>
)}
<div className="grow"></div>
{/* Action buttons */}
{isEditable ? (
<div>
<button
@ -237,10 +252,11 @@ function QuizBlockComponent(props: any) {
<div className="flex space-x-1 items-center">
<div
onClick={() => refreshUserSubmission()}
className="cursor-pointer px-2"
className="cursor-pointer p-1.5 rounded-md hover:bg-slate-200"
title="Reset answers"
>
<RefreshCcw
className="text-slate-400 cursor-pointer"
className="text-slate-500"
size={15}
/>
</div>
@ -254,8 +270,9 @@ function QuizBlockComponent(props: any) {
)}
</div>
{/* Questions section */}
{questions.map((question: Question) => (
<div key={question.question_id} className="pt-1 space-y-2">
<div key={question.question_id} className="pt-3 space-y-2">
<div className="question">
<div className="flex space-x-2 items-center">
<div className="flex-grow">
@ -269,10 +286,10 @@ function QuizBlockComponent(props: any) {
e.target.value
)
}
className="text-slate-800 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-md font-bold w-full"
className="text-slate-800 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-md font-bold w-full p-2"
></input>
) : (
<p className="text-slate-800 bg-[#00008b00] rounded-md text-md font-bold w-full">
<p className="text-slate-800 bg-[#00008b00] rounded-md text-md font-bold w-full p-2 break-words">
{question.question}
</p>
)}
@ -280,25 +297,27 @@ function QuizBlockComponent(props: any) {
{isEditable && (
<div
onClick={() => deleteQuestion(question.question_id)}
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-slate-200 hover:bg-slate-300 text-sm transition-all ease-linear cursor-pointer"
className="w-[24px] flex-none flex items-center h-[24px] rounded-lg bg-slate-200 hover:bg-slate-300 text-sm transition-all ease-linear cursor-pointer"
>
<Minus className="mx-auto text-slate-400" size={12} />
<Minus className="mx-auto text-slate-500" size={14} />
</div>
)}
</div>
<div className="answers flex py-2 space-x-3">
{/* Answers section - changed to vertical layout for better responsiveness */}
<div className="answers flex flex-col py-2 space-y-2">
{question.answers.map((answer: Answer) => (
<div
key={answer.answer_id}
className={twMerge(
'outline outline-3 pr-2 shadow w-full flex items-center space-x-2 h-[30px] bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-s rounded-lg bg-white text-sm duration-150 cursor-pointer ease-linear',
'outline outline-2 pr-2 shadow w-full flex items-stretch space-x-2 min-h-[36px] bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-lg bg-white text-sm duration-150 cursor-pointer ease-linear',
answer.correct && isEditable ? 'outline-lime-300' : 'outline-white',
userAnswers.some(
(userAnswer: any) =>
userAnswer.question_id === question.question_id &&
userAnswer.answer_id === answer.answer_id &&
!isEditable
) ? 'outline-slate-300' : '',
!isEditable && !submitted
) ? 'outline-blue-400' : '',
submitted && answer.correct ? 'outline-lime-300 text-lime' : '',
submitted &&
!answer.correct &&
@ -314,10 +333,16 @@ function QuizBlockComponent(props: any) {
>
<div
className={twMerge(
'bg-white font-bold text-base flex items-center h-full w-[40px] rounded-l-md text-slate-800',
'font-bold text-base flex items-center justify-center self-stretch w-[40px] rounded-l-md text-slate-800 bg-white',
answer.correct && isEditable
? 'bg-lime-300 text-lime-800 outline-none'
: 'bg-white',
userAnswers.some(
(userAnswer: any) =>
userAnswer.question_id === question.question_id &&
userAnswer.answer_id === answer.answer_id &&
!isEditable && !submitted
) ? 'bg-blue-400 text-white outline-none' : '',
submitted && answer.correct
? 'bg-lime-300 text-lime-800 outline-none'
: '',
@ -332,7 +357,7 @@ function QuizBlockComponent(props: any) {
: ''
)}
>
<p className="mx-auto font-bold text-sm ">
<p className="font-bold text-sm">
{getAnswerID(
question.answers.indexOf(answer),
question.question_id
@ -350,33 +375,37 @@ function QuizBlockComponent(props: any) {
)
}
placeholder="Answer"
className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-sm font-bold"
className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] border-2 border-gray-200 rounded-md border-dotted text-sm font-bold py-1.5"
></input>
) : (
<p className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] rounded-md ext-sm font-bold">
<p className="w-full mx-2 px-3 pr-6 text-neutral-600 bg-[#00008b00] rounded-md text-sm font-bold py-1.5 break-words">
{answer.answer}
</p>
)}
{isEditable && (
<div className="flex space-x-1 items-center">
<div
onClick={() =>
onClick={(e) => {
e.stopPropagation();
markAnswerCorrect(
question.question_id,
answer.answer_id
)
}
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-lime-300 hover:bg-lime-400 transition-all ease-linear text-sm cursor-pointer "
);
}}
className="w-[24px] flex-none flex items-center h-[24px] rounded-lg bg-lime-300 hover:bg-lime-400 transition-all ease-linear text-sm cursor-pointer"
title={answer.correct ? "Mark as incorrect" : "Mark as correct"}
>
<Check className="mx-auto text-lime-800" size={12} />
<Check className="mx-auto text-lime-800" size={14} />
</div>
<div
onClick={() =>
deleteAnswer(question.question_id, answer.answer_id)
}
className="w-[20px] flex-none flex items-center h-[20px] rounded-lg bg-slate-200 hover:bg-slate-300 text-sm transition-all ease-linear cursor-pointer"
onClick={(e) => {
e.stopPropagation();
deleteAnswer(question.question_id, answer.answer_id);
}}
className="w-[24px] flex-none flex items-center h-[24px] rounded-lg bg-slate-200 hover:bg-slate-300 text-sm transition-all ease-linear cursor-pointer"
title="Delete answer"
>
<Minus className="mx-auto text-slate-400" size={12} />
<Minus className="mx-auto text-slate-500" size={14} />
</div>
</div>
)}
@ -385,9 +414,10 @@ function QuizBlockComponent(props: any) {
{isEditable && (
<div
onClick={() => addAnswer(question.question_id)}
className="outline outline-3 w-[30px] flex-none flex items-center h-[30px] outline-white hover:bg-opacity-100 hover:shadow-md rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear"
className="outline outline-2 w-full flex-none flex items-center h-[36px] outline-white hover:bg-opacity-100 hover:shadow-md rounded-lg bg-white text-sm hover:scale-[1.01] active:scale-[1.02] duration-150 cursor-pointer ease-linear justify-center"
>
<Plus className="mx-auto text-slate-800" size={15} />
<Plus className="text-slate-800 mr-1" size={15} />
<span className="text-slate-800 text-sm">Add Answer</span>
</div>
)}
</div>

View file

@ -205,7 +205,7 @@ export const ToolbarButtons = ({ editor, props }: any) => {
</ToolBtn>
</ToolTip>
<ToolTip content={'YouTube video'}>
<ToolBtn onClick={() => addYoutubeVideo()}>
<ToolBtn onClick={() => editor.chain().focus().insertContent({ type: 'blockEmbed' }).run()}>
<SiYoutube size={15} />
</ToolBtn>
</ToolTip>