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) => {
|
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]
|
const file = event.target.files[0]
|
||||||
|
|
||||||
setLocalUploadFile(file)
|
setLocalUploadFile(file)
|
||||||
|
|
@ -69,21 +75,30 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setError('')
|
setError('')
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAssignmentTaskSubmissionFromUserUI() {
|
async function getAssignmentTaskSubmissionFromUserUI() {
|
||||||
|
if (!access_token) {
|
||||||
|
// Silently fail if not authenticated
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (assignmentTaskUUID) {
|
if (assignmentTaskUUID) {
|
||||||
const res = await getAssignmentTaskSubmissionsMe(assignmentTaskUUID, assignment.assignment_object.assignment_uuid, access_token);
|
const res = await getAssignmentTaskSubmissionsMe(assignmentTaskUUID, assignment.assignment_object.assignment_uuid, access_token);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setUserSubmissions(res.data.task_submission);
|
setUserSubmissions(res.data.task_submission);
|
||||||
setInitialUserSubmissions(res.data.task_submission);
|
setInitialUserSubmissions(res.data.task_submission);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitFC = async () => {
|
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
|
// Save the quiz to the server
|
||||||
const values = {
|
const values = {
|
||||||
task_submission: userSubmissions,
|
task_submission: userSubmissions,
|
||||||
|
|
@ -105,13 +120,17 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getAssignmentTaskUI() {
|
async function getAssignmentTaskUI() {
|
||||||
|
if (!access_token) {
|
||||||
|
// Silently fail if not authenticated
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (assignmentTaskUUID) {
|
if (assignmentTaskUUID) {
|
||||||
const res = await getAssignmentTask(assignmentTaskUUID, access_token);
|
const res = await getAssignmentTask(assignmentTaskUUID, access_token);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setAssignmentTask(res.data);
|
setAssignmentTask(res.data);
|
||||||
setAssignmentTaskOutsideProvider(res.data);
|
setAssignmentTaskOutsideProvider(res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,6 +148,11 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
|
||||||
/* GRADING VIEW CODE */
|
/* GRADING VIEW CODE */
|
||||||
const [userSubmissionObject, setUserSubmissionObject] = useState<any>(null);
|
const [userSubmissionObject, setUserSubmissionObject] = useState<any>(null);
|
||||||
async function getAssignmentTaskSubmissionFromIdentifiedUserUI() {
|
async function getAssignmentTaskSubmissionFromIdentifiedUserUI() {
|
||||||
|
if (!access_token) {
|
||||||
|
// Silently fail if not authenticated
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (assignmentTaskUUID && user_id) {
|
if (assignmentTaskUUID && user_id) {
|
||||||
const res = await getAssignmentTaskSubmissionsUser(assignmentTaskUUID, user_id, assignment.assignment_object.assignment_uuid, access_token);
|
const res = await getAssignmentTaskSubmissionsUser(assignmentTaskUUID, user_id, assignment.assignment_object.assignment_uuid, access_token);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
|
@ -136,7 +160,6 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
|
||||||
setUserSubmissionObject(res.data);
|
setUserSubmissionObject(res.data);
|
||||||
setInitialUserSubmissions(res.data.task_submission);
|
setInitialUserSubmissions(res.data.task_submission);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,31 +209,29 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
|
||||||
return (
|
return (
|
||||||
<AssignmentBoxUI submitFC={submitFC} showSavingDisclaimer={showSavingDisclaimer} view={view} gradeCustomFC={gradeCustomFC} currentPoints={userSubmissionObject?.grade} maxPoints={assignmentTaskOutsideProvider?.max_grade_value} type="file">
|
<AssignmentBoxUI submitFC={submitFC} showSavingDisclaimer={showSavingDisclaimer} view={view} gradeCustomFC={gradeCustomFC} currentPoints={userSubmissionObject?.grade} maxPoints={assignmentTaskOutsideProvider?.max_grade_value} type="file">
|
||||||
{view === 'teacher' && (
|
{view === 'teacher' && (
|
||||||
<div className='flex py-5 text-sm justify-center mx-auto space-x-2 text-slate-500'>
|
<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={20} />
|
<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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{view === 'custom-grading' && (
|
{view === 'custom-grading' && (
|
||||||
<div className='flex flex-col space-y-1'>
|
<div className='flex flex-col space-y-4 w-full px-2 sm:px-0'>
|
||||||
<div className='flex py-5 text-sm justify-center mx-auto space-x-2 text-slate-500'>
|
<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={20} />
|
<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>
|
<p>Please download the file and grade it manually, then input the grade above</p>
|
||||||
</div>
|
</div>
|
||||||
{userSubmissions.fileUUID && !isLoading && assignmentTaskUUID && (
|
{userSubmissions.fileUUID && !isLoading && assignmentTaskUUID && (
|
||||||
<Link
|
<Link
|
||||||
href={getTaskFileSubmissionDir(org?.org_uuid, assignment.course_object.course_uuid, assignment.activity_object.activity_uuid, assignment.assignment_object.assignment_uuid, assignmentTaskUUID, userSubmissions.fileUUID)}
|
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'
|
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'>
|
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-green-500 rounded-full px-1.5 py-1.5 text-white flex justify-center items-center'>
|
<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={15} />
|
<Cloud size={14} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className='flex space-x-2 mt-2 items-center'>
|
||||||
|
<File size={18} className="text-emerald-500" />
|
||||||
className='flex space-x-2 mt-2'>
|
<div className='font-medium text-xs sm:text-sm uppercase break-all'>
|
||||||
<File size={20} className='' />
|
|
||||||
<div className='font-semibold text-sm uppercase'>
|
|
||||||
{`${userSubmissions.fileUUID.slice(0, 8)}...${userSubmissions.fileUUID.slice(-4)}`}
|
{`${userSubmissions.fileUUID.slice(0, 8)}...${userSubmissions.fileUUID.slice(-4)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -220,66 +241,72 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
|
||||||
)}
|
)}
|
||||||
{view === 'student' && (
|
{view === 'student' && (
|
||||||
<>
|
<>
|
||||||
<div className="w-auto bg-white rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow">
|
<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">
|
<div className="flex flex-col justify-center items-center h-full w-full">
|
||||||
<div className="flex flex-col justify-center items-center">
|
<div className="flex flex-col justify-center items-center w-full max-w-full">
|
||||||
<div className="flex flex-col justify-center items-center">
|
<div className="flex flex-col justify-center items-center w-full">
|
||||||
{error && (
|
{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="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-sm font-semibold">{error}</div>
|
<div className="text-xs sm:text-sm font-medium">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{localUploadFile && !isLoading && (
|
{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='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-green-500 rounded-full px-1.5 py-1.5 text-white flex justify-center items-center'>
|
<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={15} />
|
<Cloud size={14} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex space-x-2 mt-2'>
|
<div className='flex space-x-2 mt-2 items-center'>
|
||||||
|
<File size={18} className="text-emerald-500" />
|
||||||
<File size={20} className='' />
|
<div className='font-medium text-xs sm:text-sm uppercase break-all'>
|
||||||
<div className='font-semibold text-sm uppercase'>
|
{localUploadFile.name.length > 20
|
||||||
{localUploadFile.name}
|
? `${localUploadFile.name.slice(0, 10)}...${localUploadFile.name.slice(-10)}`
|
||||||
|
: localUploadFile.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userSubmissions.fileUUID && !isLoading && !localUploadFile && (
|
{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='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-green-500 rounded-full px-1.5 py-1.5 text-white flex justify-center items-center'>
|
<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={15} />
|
<Cloud size={14} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex space-x-2 mt-2'>
|
<div className='flex space-x-2 mt-2 items-center'>
|
||||||
|
<File size={18} className="text-emerald-500" />
|
||||||
<File size={20} className='' />
|
<div className='font-medium text-xs sm:text-sm uppercase break-all'>
|
||||||
<div className='font-semibold text-sm uppercase'>
|
|
||||||
{`${userSubmissions.fileUUID.slice(0, 8)}...${userSubmissions.fileUUID.slice(-4)}`}
|
{`${userSubmissions.fileUUID.slice(0, 8)}...${userSubmissions.fileUUID.slice(-4)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className='flex pt-4 font-semibold space-x-1.5 text-xs items-center text-gray-500 '>
|
<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={16} />
|
<Info size={15} className="mx-auto sm:mx-0 text-slate-400" />
|
||||||
<p>Allowed formats: pdf, docx, mp4, jpg, jpeg, png, pptx, zip</p>
|
<p>Allowed formats: pdf, docx, mp4, jpg, jpeg, png, pptx, zip</p>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{!access_token ? (
|
||||||
<div className="flex justify-center items-center">
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="fileInput"
|
id="fileInput"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileChange}
|
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">
|
<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={16} className="mr-2" />
|
<Loader size={15} className="mr-2" />
|
||||||
<span>Loading</span>
|
<span>Loading</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center w-full mt-5">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id={"fileInput_" + assignmentTaskUUID}
|
id={"fileInput_" + assignmentTaskUUID}
|
||||||
|
|
@ -287,10 +314,10 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<button
|
<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()}
|
onClick={() => document.getElementById("fileInput_" + assignmentTaskUUID)?.click()}
|
||||||
>
|
>
|
||||||
<UploadCloud size={16} className="mr-2" />
|
<UploadCloud size={15} className="mr-2" />
|
||||||
<span>Submit File</span>
|
<span>Submit File</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext'
|
import { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext'
|
||||||
import { BookPlus, BookUser, EllipsisVertical, FileUp, Forward, InfoIcon, ListTodo, Save } from 'lucide-react'
|
import { BookPlus, BookUser, EllipsisVertical, FileUp, Forward, InfoIcon, ListTodo, Save } from 'lucide-react'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
|
||||||
type AssignmentBoxProps = {
|
type AssignmentBoxProps = {
|
||||||
type: 'quiz' | 'file'
|
type: 'quiz' | 'file'
|
||||||
|
|
@ -13,21 +14,25 @@ type AssignmentBoxProps = {
|
||||||
gradeCustomFC?: (grade: number) => void
|
gradeCustomFC?: (grade: number) => void
|
||||||
showSavingDisclaimer?: boolean
|
showSavingDisclaimer?: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitFC, gradeFC, gradeCustomFC, showSavingDisclaimer, children }: AssignmentBoxProps) {
|
function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitFC, gradeFC, gradeCustomFC, showSavingDisclaimer, children }: AssignmentBoxProps) {
|
||||||
const [customGrade, setCustomGrade] = React.useState<number>(0)
|
const [customGrade, setCustomGrade] = React.useState<number>(0)
|
||||||
const submission = useAssignmentSubmission() as any
|
const submission = useAssignmentSubmission() as any
|
||||||
|
const session = useLHSession() as any
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(submission)
|
console.log(submission)
|
||||||
}
|
}, [submission])
|
||||||
, [submission])
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
const isAuthenticated = session?.status === 'authenticated'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col px-6 py-4 nice-shadow rounded-md bg-slate-100/30'>
|
<div className='flex flex-col px-3 sm: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 flex-col sm:flex-row sm:justify-between sm:space-x-2 pb-2 text-slate-400 sm:items-center'>
|
||||||
<div className='flex space-x-1 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'>
|
<div className='text-lg font-semibold'>
|
||||||
{type === 'quiz' &&
|
{type === 'quiz' &&
|
||||||
<div className='flex space-x-1.5 items-center'>
|
<div className='flex space-x-1.5 items-center'>
|
||||||
|
|
@ -41,26 +46,27 @@ function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitF
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className='flex items-center space-x-1'>
|
<div className='flex items-center space-x-1'>
|
||||||
<EllipsisVertical size={15} />
|
<EllipsisVertical size={15} />
|
||||||
</div>
|
</div>
|
||||||
{view === 'teacher' &&
|
{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} />
|
<BookUser size={12} />
|
||||||
<p>Teacher view</p>
|
<p>Teacher view</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{maxPoints &&
|
{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} />
|
<BookPlus size={12} />
|
||||||
<p>{maxPoints} points</p>
|
<p>{maxPoints} points</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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 &&
|
{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} />
|
<InfoIcon size={14} />
|
||||||
<p className='text-xs'>Don't forget to save your progress</p>
|
<p className='text-xs'>Don't forget to save your progress</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,11 +82,11 @@ function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitF
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
{/* Student button */}
|
{/* Student button - only show if authenticated */}
|
||||||
{view === 'student' && submission && submission.length <= 0 &&
|
{view === 'student' && isAuthenticated && submission && submission.length <= 0 &&
|
||||||
<div
|
<div
|
||||||
onClick={() => submitFC && submitFC()}
|
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} />
|
<Forward size={14} />
|
||||||
<p className='text-xs font-semibold'>Save your progress</p>
|
<p className='text-xs font-semibold'>Save your progress</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,11 +95,11 @@ function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitF
|
||||||
{/* Grading button */}
|
{/* Grading button */}
|
||||||
{view === 'grading' &&
|
{view === 'grading' &&
|
||||||
<div
|
<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'>
|
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>
|
<p className='font-semibold px-2 text-xs text-orange-700'>Current points: {currentPoints}</p>
|
||||||
<div
|
<div
|
||||||
onClick={() => gradeFC && gradeFC()}
|
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} />
|
<BookPlus size={14} />
|
||||||
<p className='text-xs font-semibold'>Grade</p>
|
<p className='text-xs font-semibold'>Grade</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,18 +109,23 @@ function AssignmentBoxUI({ type, view, currentPoints, maxPoints, saveFC, submitF
|
||||||
{/* CustomGrading button */}
|
{/* CustomGrading button */}
|
||||||
{view === 'custom-grading' && maxPoints &&
|
{view === 'custom-grading' && maxPoints &&
|
||||||
<div
|
<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'>
|
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>
|
<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
|
<input
|
||||||
onChange={(e) => setCustomGrade(parseInt(e.target.value))}
|
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" />
|
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
|
<div
|
||||||
onClick={() => gradeCustomFC && gradeCustomFC(customGrade)}
|
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'>
|
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} />
|
<BookPlus size={14} />
|
||||||
<p className='text-xs font-semibold'>Grade</p>
|
<p className='text-xs font-semibold'>Grade</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,19 @@ function AssignmentStudentActivity() {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col space-y-6'>
|
<div className='flex flex-col space-y-4 md:space-y-6'>
|
||||||
<div className='flex flex-row justify-center space-x-3 items-center '>
|
<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='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'>
|
<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={18} />
|
<Backpack size={16} className="md:size-18" />
|
||||||
<p className='font-semibold'>Assignment</p>
|
<p className='font-semibold'>Assignment</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className='flex space-x-2 items-center'>
|
<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 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} />
|
<Calendar size={14} />
|
||||||
<p className='font-semibold'>Due Date</p>
|
<p className='font-semibold'>Due Date</p>
|
||||||
<p className='font-semibold'>{assignments?.assignment_object?.due_date}</p>
|
<p className='font-semibold'>{assignments?.assignment_object?.due_date}</p>
|
||||||
|
|
@ -38,18 +38,19 @@ function AssignmentStudentActivity() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full rounded-full bg-slate-500/5 nice-shadow h-[2px]'></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) => {
|
{assignments && assignments?.assignment_tasks?.sort((a: any, b: any) => a.id - b.id).map((task: any, index: number) => {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col space-y-2' key={task.assignment_task_uuid}>
|
<div className='flex flex-col space-y-2' key={task.assignment_task_uuid}>
|
||||||
<div className='flex justify-between py-2'>
|
<div className='flex flex-col md:flex-row md:justify-between py-2 space-y-2 md:space-y-0'>
|
||||||
<div className='flex space-x-2 font-semibold text-slate-800'>
|
<div className='flex flex-wrap space-x-2 font-semibold text-slate-800'>
|
||||||
<p>Task {index + 1} : </p>
|
<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>
|
||||||
<div className='flex space-x-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
<div
|
<div
|
||||||
onClick={() => alert(task.hint)}
|
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'>
|
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'
|
target='_blank'
|
||||||
download={true}
|
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} />
|
<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 && (
|
{task.reference_file && (
|
||||||
<span className='relative'>
|
<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>
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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 === '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} />}
|
{task.assignment_type === 'FILE_SUBMISSION' && <TaskFileObject key={task.assignment_task_uuid} view='student' assignmentTaskUUID={task.assignment_task_uuid} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,71 @@
|
||||||
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
||||||
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
||||||
import { AlertCircle } from 'lucide-react'
|
import { AlertCircle, X } from 'lucide-react'
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
function InfoCalloutComponent(props: any) {
|
interface CalloutOptions {
|
||||||
const editorState = useEditorProvider() as any
|
dismissible?: boolean;
|
||||||
const isEditable = editorState.isEditable
|
variant?: 'default' | 'filled' | 'outlined';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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 {
|
.content {
|
||||||
|
|
@ -32,7 +74,69 @@ const InfoCalloutWrapper = styled.div`
|
||||||
border: ${(props) =>
|
border: ${(props) =>
|
||||||
props.contentEditable ? '2px dashed #1f3a8a12' : 'none'};
|
props.contentEditable ? '2px dashed #1f3a8a12' : 'none'};
|
||||||
border-radius: 0.5rem;
|
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
|
export default InfoCalloutComponent
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,71 @@
|
||||||
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
||||||
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle, X } from 'lucide-react'
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
function WarningCalloutComponent(props: any) {
|
interface CalloutOptions {
|
||||||
const editorState = useEditorProvider() as any
|
dismissible?: boolean;
|
||||||
const isEditable = editorState.isEditable
|
variant?: 'default' | 'filled' | 'outlined';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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 {
|
.content {
|
||||||
|
|
@ -32,17 +74,69 @@ const CalloutWrapper = styled.div`
|
||||||
border: ${(props) =>
|
border: ${(props) =>
|
||||||
props.contentEditable ? '2px dashed #713f1117' : 'none'};
|
props.contentEditable ? '2px dashed #713f1117' : 'none'};
|
||||||
border-radius: 0.5rem;
|
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`
|
function WarningCalloutComponent(props: any) {
|
||||||
position: absolute;
|
const editorState = useEditorProvider() as any
|
||||||
top: 0;
|
const isEditable = editorState.isEditable
|
||||||
left: 0;
|
const [dismissed, setDismissed] = useState(false)
|
||||||
width: 1rem;
|
|
||||||
height: 100%;
|
// Extract options from props or use defaults
|
||||||
cursor: move;
|
const options: CalloutOptions = {
|
||||||
z-index: 1;
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
export default WarningCalloutComponent
|
||||||
|
|
@ -2,7 +2,7 @@ import { NodeViewWrapper } from '@tiptap/react'
|
||||||
import React, { useState, useRef, useEffect, useMemo } from 'react'
|
import React, { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
import { Upload, Link as LinkIcon, GripVertical, GripHorizontal, AlignCenter, Cuboid, Code } from 'lucide-react'
|
import { Upload, Link as LinkIcon, GripVertical, GripHorizontal, AlignCenter, Cuboid, Code } from 'lucide-react'
|
||||||
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
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 { useRouter } from 'next/navigation'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
|
@ -14,6 +14,44 @@ const SCRIPT_BASED_EMBEDS = {
|
||||||
// Add more platforms as needed
|
// 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
|
// Add new memoized component for the embed content
|
||||||
const MemoizedEmbed = React.memo(({ embedUrl, sanitizedEmbedCode, embedType }: {
|
const MemoizedEmbed = React.memo(({ embedUrl, sanitizedEmbedCode, embedType }: {
|
||||||
embedUrl: string;
|
embedUrl: string;
|
||||||
|
|
@ -43,9 +81,26 @@ const MemoizedEmbed = React.memo(({ embedUrl, sanitizedEmbedCode, embedType }: {
|
||||||
}, [embedType, sanitizedEmbedCode]);
|
}, [embedType, sanitizedEmbedCode]);
|
||||||
|
|
||||||
if (embedType === 'url' && embedUrl) {
|
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 (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
src={embedUrl}
|
src={processedUrl}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
|
@ -69,13 +124,61 @@ function EmbedObjectsComponent(props: any) {
|
||||||
const [embedWidth, setEmbedWidth] = useState(props.node.attrs.embedWidth || '100%')
|
const [embedWidth, setEmbedWidth] = useState(props.node.attrs.embedWidth || '100%')
|
||||||
const [alignment, setAlignment] = useState(props.node.attrs.alignment || 'left')
|
const [alignment, setAlignment] = useState(props.node.attrs.alignment || 'left')
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
const [parentWidth, setParentWidth] = useState<number | null>(null)
|
||||||
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
|
||||||
const resizeRef = useRef<HTMLDivElement>(null)
|
const resizeRef = useRef<HTMLDivElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const editorState = useEditorProvider() as any
|
const editorState = useEditorProvider() as any
|
||||||
const isEditable = editorState.isEditable
|
const isEditable = editorState.isEditable
|
||||||
const router = useRouter()
|
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 = [
|
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: 'GitHub', icon: SiGithub, color: '#181717', guide: 'https://emgithub.com/' },
|
||||||
{ name: 'Replit', icon: SiReplit, color: '#F26207', guide: 'https://docs.replit.com/hosting/embedding-repls' },
|
{ 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' },
|
{ 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 handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newUrl = event.target.value;
|
const newUrl = event.target.value;
|
||||||
const trimmedUrl = newUrl.trim();
|
const trimmedUrl = newUrl.trim();
|
||||||
|
|
||||||
// Only update if URL is not just whitespace
|
// Only update if URL is not just whitespace
|
||||||
if (newUrl === '' || trimmedUrl) {
|
if (newUrl === '' || trimmedUrl) {
|
||||||
|
// First sanitize with DOMPurify
|
||||||
const sanitizedUrl = DOMPurify.sanitize(newUrl);
|
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({
|
props.updateAttributes({
|
||||||
embedUrl: sanitizedUrl,
|
embedUrl: validatedUrl,
|
||||||
embedType: 'url',
|
embedType: 'url',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +325,30 @@ function EmbedObjectsComponent(props: any) {
|
||||||
window.open(guide, '_blank', 'noopener,noreferrer')
|
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
|
// Memoize the embed content
|
||||||
const embedContent = useMemo(() => (
|
const embedContent = useMemo(() => (
|
||||||
!isResizing && (embedUrl || sanitizedEmbedCode) ? (
|
!isResizing && (embedUrl || sanitizedEmbedCode) ? (
|
||||||
|
|
@ -209,73 +362,280 @@ function EmbedObjectsComponent(props: any) {
|
||||||
)
|
)
|
||||||
), [embedUrl, sanitizedEmbedCode, embedType, isResizing]);
|
), [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 (
|
return (
|
||||||
<NodeViewWrapper className="embed-block">
|
<NodeViewWrapper className="embed-block w-full" ref={containerRef}>
|
||||||
<div
|
<div
|
||||||
ref={resizeRef}
|
ref={resizeRef}
|
||||||
className={`relative bg-gray-100 rounded-lg overflow-hidden flex justify-center items-center ${alignment === 'center' ? 'mx-auto' : ''}`}
|
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 : (
|
{(embedUrl || sanitizedEmbedCode) ? (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center p-6">
|
// Show the embed content if we have a URL or code
|
||||||
<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">
|
{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) => (
|
{supportedProducts.map((product) => (
|
||||||
<button
|
<button
|
||||||
key={product.name}
|
key={product.name}
|
||||||
className="flex flex-col items-center group transition-transform hover:scale-110"
|
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 }}>
|
<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={24} color="#FFFFFF" />
|
<product.icon size={isMobile ? 16 : 24} color="#FFFFFF" />
|
||||||
</div>
|
</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{isEditable && (
|
)}
|
||||||
|
|
||||||
|
{/* 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="absolute bottom-2 left-2 flex gap-2">
|
<div className="relative mb-2">
|
||||||
<button
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-blue-500">
|
||||||
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} />
|
<LinkIcon size={16} />
|
||||||
</button>
|
</div>
|
||||||
<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
|
<input
|
||||||
|
ref={urlInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={embedUrl}
|
value={embedUrl}
|
||||||
onChange={handleUrlChange}
|
onChange={handleUrlChange}
|
||||||
className="p-2 bg-white bg-opacity-70 rounded-md w-64"
|
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="Enter embed URL"
|
placeholder={selectedProduct ? `Paste ${selectedProduct.name} embed URL` : "Paste embed URL (YouTube, Spotify, etc.)"}
|
||||||
/>
|
autoFocus
|
||||||
) : (
|
|
||||||
<textarea
|
|
||||||
value={embedCode}
|
|
||||||
onChange={handleCodeChange}
|
|
||||||
className="p-2 bg-white bg-opacity-70 rounded-md w-64 h-20"
|
|
||||||
placeholder="Enter embed code"
|
|
||||||
/>
|
/>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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
|
<button
|
||||||
onClick={handleCenterBlock}
|
type="button"
|
||||||
className="absolute bottom-2 right-2 p-2 bg-white bg-opacity-70 rounded-md hover:bg-opacity-100 transition-opacity"
|
onClick={() => handleOpenDocs(selectedProduct.guide)}
|
||||||
|
className="text-xs text-blue-500 hover:text-blue-700 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<AlignCenter size={16} className="text-gray-600" />
|
<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>
|
</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
|
<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"
|
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')}
|
onMouseDown={(e) => handleResizeStart(e, 'horizontal')}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ function ImageBlockComponent(props: any) {
|
||||||
const [imageSize, setImageSize] = React.useState({
|
const [imageSize, setImageSize] = React.useState({
|
||||||
width: props.node.attrs.size ? props.node.attrs.size.width : 300,
|
width: props.node.attrs.size ? props.node.attrs.size.width : 300,
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileId = blockObject
|
const fileId = blockObject
|
||||||
? `${blockObject.content.file_id}.${blockObject.content.file_format}`
|
? `${blockObject.content.file_id}.${blockObject.content.file_format}`
|
||||||
: null
|
: null
|
||||||
|
|
@ -54,13 +55,14 @@ function ImageBlockComponent(props: any) {
|
||||||
useEffect(() => {}, [course, org])
|
useEffect(() => {}, [course, org])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="block-image">
|
<NodeViewWrapper className="block-image w-full">
|
||||||
<FileUploadBlock isEditable={isEditable} isLoading={isLoading} isEmpty={!blockObject} Icon={Image}>
|
<FileUploadBlock isEditable={isEditable} isLoading={isLoading} isEmpty={!blockObject} Icon={Image}>
|
||||||
<FileUploadBlockInput onChange={handleImageChange} accept={SUPPORTED_FILES} />
|
<FileUploadBlockInput onChange={handleImageChange} accept={SUPPORTED_FILES} />
|
||||||
<FileUploadBlockButton onClick={handleSubmit} disabled={!image}/>
|
<FileUploadBlockButton onClick={handleSubmit} disabled={!image}/>
|
||||||
</FileUploadBlock>
|
</FileUploadBlock>
|
||||||
|
|
||||||
{blockObject && (
|
{blockObject && isEditable && (
|
||||||
|
<div className="w-full flex justify-center">
|
||||||
<Resizable
|
<Resizable
|
||||||
defaultSize={{ width: imageSize.width, height: '100%' }}
|
defaultSize={{ width: imageSize.width, height: '100%' }}
|
||||||
handleStyles={{
|
handleStyles={{
|
||||||
|
|
@ -77,22 +79,24 @@ function ImageBlockComponent(props: any) {
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
margin: 'auto',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
}}
|
}}
|
||||||
maxWidth={1000}
|
maxWidth="100%"
|
||||||
minWidth={200}
|
minWidth={200}
|
||||||
|
enable={{ right: true }}
|
||||||
onResizeStop={(e, direction, ref, d) => {
|
onResizeStop={(e, direction, ref, d) => {
|
||||||
|
const newWidth = Math.min(imageSize.width + d.width, ref.parentElement?.clientWidth || 1000);
|
||||||
props.updateAttributes({
|
props.updateAttributes({
|
||||||
size: {
|
size: {
|
||||||
width: imageSize.width + d.width,
|
width: newWidth,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
setImageSize({
|
setImageSize({
|
||||||
width: imageSize.width + d.width,
|
width: newWidth,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -106,10 +110,31 @@ function ImageBlockComponent(props: any) {
|
||||||
'imageBlock'
|
'imageBlock'
|
||||||
)}`}
|
)}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-lg shadow "
|
className="rounded-lg shadow max-w-full h-auto"
|
||||||
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Resizable>
|
</Resizable>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{blockObject && !isEditable && (
|
||||||
|
<div className="w-full flex justify-center">
|
||||||
|
<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: imageSize.width, maxWidth: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div>
|
<div>
|
||||||
<AlertTriangle color="#e1e0e0" size={50} />
|
<AlertTriangle color="#e1e0e0" size={50} />
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,241 @@ import React from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import { BlockMath } from 'react-katex'
|
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 Link from 'next/link'
|
||||||
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
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) {
|
function MathEquationBlockComponent(props: any) {
|
||||||
const [equation, setEquation] = React.useState(props.node.attrs.math_equation)
|
const [equation, setEquation] = React.useState(props.node.attrs.math_equation)
|
||||||
const [isEditing, setIsEditing] = React.useState(true)
|
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 editorState = useEditorProvider() as any
|
||||||
const isEditable = editorState.isEditable
|
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)
|
setEquation(event.target.value)
|
||||||
props.updateAttributes({
|
props.updateAttributes({
|
||||||
math_equation: equation,
|
math_equation: event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,83 +248,209 @@ function MathEquationBlockComponent(props: any) {
|
||||||
//setIsEditing(false);
|
//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 (
|
return (
|
||||||
<NodeViewWrapper className="block-math-equation">
|
<NodeViewWrapper className="block-math-equation">
|
||||||
<MathEqWrapper className="flex flex-col space-y-2 bg-gray-50 shadow-inner rounded-lg py-7 px-5">
|
<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>
|
<BlockMath>{equation}</BlockMath>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isEditing && isEditable && (
|
{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>
|
<EditBar>
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
value={equation}
|
value={equation}
|
||||||
onChange={handleEquationChange}
|
onChange={handleEquationChange}
|
||||||
placeholder="Insert a Math Equation (LaTeX)"
|
placeholder="Insert a Math Equation (LaTeX)"
|
||||||
type="text"
|
type="text"
|
||||||
|
className="focus:ring-1 focus:ring-blue-300"
|
||||||
/>
|
/>
|
||||||
<button className="opacity-1" onClick={() => saveEquation()}>
|
<SaveButton
|
||||||
<Save size={15}></Save>
|
onClick={() => saveEquation()}
|
||||||
</button>
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<Save size={15} />
|
||||||
|
</SaveButton>
|
||||||
</EditBar>
|
</EditBar>
|
||||||
<span className="pt-2 text-zinc-500 text-sm">
|
|
||||||
Please refer to this{' '}
|
<InfoLink className="flex items-center text-zinc-500 text-sm">
|
||||||
|
<span>Please refer to this</span>
|
||||||
<Link
|
<Link
|
||||||
className="text-zinc-900 after:content-['↗']"
|
className="inline-flex items-center mx-1 text-blue-600 hover:text-blue-800 font-medium"
|
||||||
href="https://katex.org/docs/supported.html"
|
href="https://katex.org/docs/supported.html"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{' '}
|
|
||||||
guide
|
guide
|
||||||
</Link>{' '}
|
<ExternalLink size={12} className="ml-1" />
|
||||||
for supported TeX functions{' '}
|
</Link>
|
||||||
</span>
|
<span>for supported TeX functions</span>
|
||||||
</>
|
</InfoLink>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</MathEqWrapper>
|
</MathEqWrapper>
|
||||||
|
</motion.div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MathEquationBlockComponent
|
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 = () => {
|
const refreshUserSubmission = () => {
|
||||||
setUserAnswers([])
|
setUserAnswers([])
|
||||||
setSubmitted(false)
|
setSubmitted(false)
|
||||||
|
setSubmissionMessage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUserSubmission = () => {
|
const handleUserSubmission = () => {
|
||||||
|
|
@ -124,7 +125,7 @@ function QuizBlockComponent(props: any) {
|
||||||
correct: false,
|
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(
|
const question: any = questions.find(
|
||||||
(question: Question) => question.question_id === question_id
|
(question: Question) => question.question_id === question_id
|
||||||
)
|
)
|
||||||
|
|
@ -206,11 +207,11 @@ function QuizBlockComponent(props: any) {
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="block-quiz">
|
<NodeViewWrapper className="block-quiz">
|
||||||
<div
|
<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-3 sm:px-5 py-2 bg-slate-100 transition-all ease-linear"
|
||||||
className="rounded-xl 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">
|
{/* Header section */}
|
||||||
{submitted && submissionMessage == 'All answers are correct!' && (
|
<div className="flex flex-wrap gap-2 pt-1 items-center text-sm">
|
||||||
|
{submitted && submissionMessage === 'All answers are correct!' && (
|
||||||
<ReactConfetti
|
<ReactConfetti
|
||||||
numberOfPieces={submitted ? 1400 : 0}
|
numberOfPieces={submitted ? 1400 : 0}
|
||||||
recycle={false}
|
recycle={false}
|
||||||
|
|
@ -223,7 +224,21 @@ function QuizBlockComponent(props: any) {
|
||||||
Quiz
|
Quiz
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? (
|
{isEditable ? (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -237,10 +252,11 @@ function QuizBlockComponent(props: any) {
|
||||||
<div className="flex space-x-1 items-center">
|
<div className="flex space-x-1 items-center">
|
||||||
<div
|
<div
|
||||||
onClick={() => refreshUserSubmission()}
|
onClick={() => refreshUserSubmission()}
|
||||||
className="cursor-pointer px-2"
|
className="cursor-pointer p-1.5 rounded-md hover:bg-slate-200"
|
||||||
|
title="Reset answers"
|
||||||
>
|
>
|
||||||
<RefreshCcw
|
<RefreshCcw
|
||||||
className="text-slate-400 cursor-pointer"
|
className="text-slate-500"
|
||||||
size={15}
|
size={15}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,8 +270,9 @@ function QuizBlockComponent(props: any) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Questions section */}
|
||||||
{questions.map((question: Question) => (
|
{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="question">
|
||||||
<div className="flex space-x-2 items-center">
|
<div className="flex space-x-2 items-center">
|
||||||
<div className="flex-grow">
|
<div className="flex-grow">
|
||||||
|
|
@ -269,10 +286,10 @@ function QuizBlockComponent(props: any) {
|
||||||
e.target.value
|
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>
|
></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}
|
{question.question}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -280,25 +297,27 @@ function QuizBlockComponent(props: any) {
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<div
|
<div
|
||||||
onClick={() => deleteQuestion(question.question_id)}
|
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>
|
</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) => (
|
{question.answers.map((answer: Answer) => (
|
||||||
<div
|
<div
|
||||||
key={answer.answer_id}
|
key={answer.answer_id}
|
||||||
className={twMerge(
|
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',
|
answer.correct && isEditable ? 'outline-lime-300' : 'outline-white',
|
||||||
userAnswers.some(
|
userAnswers.some(
|
||||||
(userAnswer: any) =>
|
(userAnswer: any) =>
|
||||||
userAnswer.question_id === question.question_id &&
|
userAnswer.question_id === question.question_id &&
|
||||||
userAnswer.answer_id === answer.answer_id &&
|
userAnswer.answer_id === answer.answer_id &&
|
||||||
!isEditable
|
!isEditable && !submitted
|
||||||
) ? 'outline-slate-300' : '',
|
) ? 'outline-blue-400' : '',
|
||||||
submitted && answer.correct ? 'outline-lime-300 text-lime' : '',
|
submitted && answer.correct ? 'outline-lime-300 text-lime' : '',
|
||||||
submitted &&
|
submitted &&
|
||||||
!answer.correct &&
|
!answer.correct &&
|
||||||
|
|
@ -314,10 +333,16 @@ function QuizBlockComponent(props: any) {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
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
|
answer.correct && isEditable
|
||||||
? 'bg-lime-300 text-lime-800 outline-none'
|
? 'bg-lime-300 text-lime-800 outline-none'
|
||||||
: 'bg-white',
|
: '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
|
submitted && answer.correct
|
||||||
? 'bg-lime-300 text-lime-800 outline-none'
|
? '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(
|
{getAnswerID(
|
||||||
question.answers.indexOf(answer),
|
question.answers.indexOf(answer),
|
||||||
question.question_id
|
question.question_id
|
||||||
|
|
@ -350,33 +375,37 @@ function QuizBlockComponent(props: any) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="Answer"
|
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>
|
></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}
|
{answer.answer}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<div className="flex space-x-1 items-center">
|
<div className="flex space-x-1 items-center">
|
||||||
<div
|
<div
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
markAnswerCorrect(
|
markAnswerCorrect(
|
||||||
question.question_id,
|
question.question_id,
|
||||||
answer.answer_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>
|
||||||
<div
|
<div
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
deleteAnswer(question.question_id, answer.answer_id)
|
e.stopPropagation();
|
||||||
}
|
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"
|
}}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -385,9 +414,10 @@ function QuizBlockComponent(props: any) {
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<div
|
<div
|
||||||
onClick={() => addAnswer(question.question_id)}
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ export const ToolbarButtons = ({ editor, props }: any) => {
|
||||||
</ToolBtn>
|
</ToolBtn>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
<ToolTip content={'YouTube video'}>
|
<ToolTip content={'YouTube video'}>
|
||||||
<ToolBtn onClick={() => addYoutubeVideo()}>
|
<ToolBtn onClick={() => editor.chain().focus().insertContent({ type: 'blockEmbed' }).run()}>
|
||||||
<SiYoutube size={15} />
|
<SiYoutube size={15} />
|
||||||
</ToolBtn>
|
</ToolBtn>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue