mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #445 from learnhouse/feat/optimize-mobile-blocks
Optimize Dynamic Activities Blocks for Mobile
This commit is contained in:
commit
d567be44d9
10 changed files with 1343 additions and 344 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue