feat: update CMS service handlers and main application logic.

This commit is contained in:
2025-12-22 13:54:35 -03:00
parent 57b8d7c0a1
commit 32f71852d9
59 changed files with 9125 additions and 59 deletions
@@ -0,0 +1,26 @@
"use client";
interface DescriptionPlayerProps {
id: string;
title?: string;
content: string;
}
export default function DescriptionPlayer({ id, title, content }: DescriptionPlayerProps) {
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
{title || "Overview"}
</h3>
</div>
<div className="prose prose-invert prose-lg max-w-none">
{/* We can use a markdown parser here later if desired, for now simple multiline text */}
<div className="text-gray-300 leading-relaxed whitespace-pre-wrap font-medium">
{content}
</div>
</div>
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useState, useMemo } from "react";
interface FillInTheBlanksPlayerProps {
id: string;
title?: string;
content: string;
}
export default function FillInTheBlanksPlayer({ id, title, content }: FillInTheBlanksPlayerProps) {
const [userAnswers, setUserAnswers] = useState<string[]>([]);
const [submitted, setSubmitted] = useState(false);
// Parse content to find blanks
const parsed = useMemo(() => {
const parts: { type: 'text' | 'blank'; value?: string; index?: number; answer?: string }[] = [];
const answers: string[] = [];
let lastIndex = 0;
const regex = /\[\[(.*?)\]\]/g;
let match;
while ((match = regex.exec(content)) !== null) {
parts.push({ type: 'text', value: content.substring(lastIndex, match.index) });
const answer = match[1];
parts.push({ type: 'blank', index: answers.length, answer });
answers.push(answer);
lastIndex = regex.lastIndex;
}
parts.push({ type: 'text', value: content.substring(lastIndex) });
return { parts, answers };
}, [content]);
const handleReset = () => {
setSubmitted(false);
setUserAnswers([]);
};
const isCorrect = (index: number) => {
return userAnswers[index]?.trim().toLowerCase() === parsed.answers[index]?.trim().toLowerCase();
};
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
{title || "Fill in the Blanks"}
</h3>
</div>
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
<div className="text-lg leading-loose text-gray-100">
{parsed.parts.map((part, i) => (
part.type === 'text' ? (
<span key={i}>{part.value}</span>
) : (
<input
key={i}
type="text"
value={userAnswers[part.index!] || ""}
onChange={(e) => {
const newAnswers = [...userAnswers];
newAnswers[part.index!] = e.target.value;
setUserAnswers(newAnswers);
}}
disabled={submitted}
className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted
? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10")
: "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5"
}`}
style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }}
placeholder="..."
/>
)
))}
</div>
{!submitted && parsed.answers.length > 0 && (
<button
onClick={() => setSubmitted(true)}
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
>
Validate Answers
</button>
)}
{submitted && (
<button
onClick={handleReset}
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
>
Try Again
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,114 @@
"use client";
import { useState, useMemo } from "react";
interface MatchingPair {
left: string;
right: string;
}
interface MatchingPlayerProps {
id: string;
title?: string;
pairs: MatchingPair[];
}
export default function MatchingPlayer({ id, title, pairs }: MatchingPlayerProps) {
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
const [matches, setMatches] = useState<Record<number, number>>({});
const [submitted, setSubmitted] = useState(false);
const shuffledRight = useMemo(() => {
return (pairs || [])
.map((p, i) => ({ value: p.right, originalIdx: i }))
.sort(() => Math.random() - 0.5);
}, [pairs]);
const handleMatch = (leftIdx: number, rightIdx: number) => {
if (submitted) return;
setMatches(prev => ({ ...prev, [leftIdx]: rightIdx }));
setSelectedLeft(null);
};
const handleReset = () => {
setSubmitted(false);
setMatches({});
setSelectedLeft(null);
};
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
{title || "Concept Matching"}
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 p-8 glass border-white/5 rounded-3xl relative">
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Term</label>
{(pairs || []).map((pair, i) => (
<button
key={i}
onClick={() => !submitted && setSelectedLeft(i)}
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" :
matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
"border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
}`}
>
{pair.left}
</button>
))}
</div>
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Definition</label>
{shuffledRight.map((item, i) => {
const matchedLeftIdx = Object.keys(matches).find(k => matches[parseInt(k)] === item.originalIdx);
const isCorrect = submitted && matchedLeftIdx !== undefined && parseInt(matchedLeftIdx) === item.originalIdx;
const isWrong = submitted && matchedLeftIdx !== undefined && parseInt(matchedLeftIdx) !== item.originalIdx;
return (
<button
key={i}
disabled={selectedLeft === null || submitted}
onClick={() => handleMatch(selectedLeft!, item.originalIdx)}
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft !== null && matchedLeftIdx === undefined ? "hover:border-blue-500/50 hover:bg-white/5" : ""
} ${isCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
isWrong ? "border-red-500 bg-red-500/20 text-red-100" :
matchedLeftIdx !== undefined ? "border-blue-500/30 bg-blue-500/5 text-blue-400" :
"border-white/5 bg-white/5 text-gray-200"
}`}
>
<div className="flex items-center justify-between">
<span>{item.value}</span>
{isCorrect && <span></span>}
{isWrong && <span></span>}
</div>
</button>
);
})}
</div>
<div className="md:col-span-2 pt-8 border-t border-white/5">
{!submitted && Object.keys(matches).length === (pairs || []).length && (
<button
onClick={() => setSubmitted(true)}
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
>
Validate Matching
</button>
)}
{submitted && (
<button
onClick={handleReset}
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
>
Try Again
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect } from "react";
import { Play, Lock, AlertCircle } from "lucide-react";
interface MediaPlayerProps {
id: string;
title?: string;
url: string;
media_type: 'video' | 'audio';
config?: {
maxPlays?: number;
};
}
export default function MediaPlayer({ id, title, url, media_type, config }: MediaPlayerProps) {
const [playCount, setPlayCount] = useState(0);
const [locked, setLocked] = useState(false);
const maxPlays = config?.maxPlays || 0;
useEffect(() => {
if (maxPlays > 0 && playCount >= maxPlays) {
setLocked(true);
}
}, [playCount, maxPlays]);
const handlePlay = () => {
if (locked) return;
setPlayCount(prev => prev + 1);
};
if (locked) {
return (
<div className="space-y-4" id={id}>
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">{title || "Multimedia Content"}</h3>
<div className="glass-card aspect-video flex flex-col items-center justify-center gap-6 border-red-500/20 bg-red-500/5">
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center text-red-500">
<Lock size={32} />
</div>
<div className="text-center">
<p className="text-xl font-bold text-white mb-2">Content Locked</p>
<p className="text-sm text-gray-500 max-w-xs uppercase tracking-widest font-black">
You have reached the limit of {maxPlays} plays for this content.
</p>
</div>
</div>
</div>
);
}
// Helper to format URL (handles YouTube embeds)
const getEmbedUrl = (rawUrl: string) => {
if (rawUrl.includes("youtube.com/watch?v=")) {
return rawUrl.replace("watch?v=", "embed/");
}
if (rawUrl.includes("youtu.be/")) {
return rawUrl.replace("youtu.be/", "youtube.com/embed/");
}
return rawUrl;
};
return (
<div className="space-y-6" id={id}>
<div className="flex items-center justify-between">
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">{title || "Multimedia Content"}</h3>
{maxPlays > 0 && (
<span className="text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full bg-white/5 border border-white/5 text-gray-500">
{playCount} / {maxPlays} PLAYS
</span>
)}
</div>
<div className="glass-card !p-2 overflow-hidden aspect-video relative group">
<iframe
src={getEmbedUrl(url)}
className="w-full h-full rounded-xl"
allowFullScreen
onLoad={() => {
// In a real app, we'd detect play events from the player API
// For this demo, we'll increment when the iframe loads or specific interaction
}}
/>
{/* Simulated play tracker overlay (invisible but catches first click) */}
{playCount === 0 && (
<div
onClick={handlePlay}
className="absolute inset-0 bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-black/20 transition-all"
>
<div className="w-20 h-20 rounded-full bg-blue-500 flex items-center justify-center shadow-2xl shadow-blue-500/40 group-hover:scale-110 transition-transform">
<Play size={32} className="text-white fill-white ml-2" />
</div>
</div>
)}
</div>
{maxPlays > 0 && playCount > 0 && (
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-orange-500/70 p-4 rounded-xl bg-orange-500/5 border border-orange-500/10">
<AlertCircle size={14} />
<span>Watch carefully. Content will lock after {maxPlays} plays.</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,116 @@
"use client";
import { useState, useMemo } from "react";
interface OrderingPlayerProps {
id: string;
title?: string;
items: string[];
}
export default function OrderingPlayer({ id, title, items }: OrderingPlayerProps) {
const [userOrder, setUserOrder] = useState<number[]>([]);
const [submitted, setSubmitted] = useState(false);
const shuffledItems = useMemo(() => {
return (items || [])
.map((item, i) => ({ value: item, originalIdx: i }))
.sort(() => Math.random() - 0.5);
}, [items]);
const handlePick = (originalIdx: number) => {
if (submitted) return;
if (userOrder.includes(originalIdx)) {
setUserOrder(userOrder.filter(i => i !== originalIdx));
} else {
setUserOrder([...userOrder, originalIdx]);
}
};
const handleReset = () => {
setSubmitted(false);
setUserOrder([]);
};
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
{title || "Sequence Ordering"}
</h3>
</div>
<div className="space-y-8 p-8 glass border-white/5 rounded-3xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Available Items</label>
<div className="flex flex-wrap gap-3">
{shuffledItems.map((item, i) => {
const isPicked = userOrder.includes(item.originalIdx);
return (
<button
key={i}
disabled={isPicked || submitted}
onClick={() => handlePick(item.originalIdx)}
className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" :
"border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
}`}
>
{item.value}
</button>
);
})}
</div>
</div>
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-4 block">Your Sequence</label>
<div className="space-y-3">
{userOrder.length === 0 && <p className="text-xs text-gray-600 italic py-4">Click items to build the sequence...</p>}
{userOrder.map((idx, i) => {
const isItemCorrect = submitted && idx === i;
const isItemWrong = submitted && idx !== i;
return (
<div
key={i}
onClick={() => !submitted && handlePick(idx)}
className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
"border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
}`}
>
<span className="opacity-50 text-xs">{i + 1}.</span>
<span className="flex-1">{(items || [])[idx]}</span>
{!submitted && <span className="text-xs opacity-50">×</span>}
{isItemCorrect && <span></span>}
{isItemWrong && <span></span>}
</div>
);
})}
</div>
</div>
</div>
<div className="pt-8 border-t border-white/5">
{!submitted && userOrder.length === (items || []).length && (
<button
onClick={() => setSubmitted(true)}
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
>
Validate Sequence
</button>
)}
{submitted && (
<button
onClick={handleReset}
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
>
Try Again
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
interface QuizQuestion {
id: string;
question: string;
options: string[];
correct: number[];
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
}
interface QuizPlayerProps {
id: string;
title?: string;
quizData: {
questions: QuizQuestion[];
};
}
export default function QuizPlayer({ id, title, quizData }: QuizPlayerProps) {
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
const [submitted, setSubmitted] = useState(false);
const questions = quizData?.questions || [];
const handleAnswer = (qId: string, optionIndex: number, isMulti: boolean) => {
if (submitted) return;
setUserAnswers(prev => {
const current = prev[qId] || [];
if (isMulti) {
const next = current.includes(optionIndex)
? current.filter(i => i !== optionIndex)
: [...current, optionIndex].sort((a, b) => a - b);
return { ...prev, [qId]: next };
} else {
return { ...prev, [qId]: [optionIndex] };
}
});
};
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
{title || "Knowledge Check"}
</h3>
</div>
<div className="space-y-8">
{questions.map((q) => (
<div key={q.id} className="space-y-4 p-8 glass border-white/5 rounded-3xl">
<h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4>
<div className="grid gap-3">
{q.options.map((opt, oIdx) => {
const isSelected = userAnswers[q.id]?.includes(oIdx);
const isCorrect = q.correct?.includes(oIdx);
const isActuallyCorrect = isCorrect && isSelected;
const isWrongSelection = !isCorrect && isSelected;
const missedCorrect = isCorrect && !isSelected;
let style = "glass border-white/10 hover:bg-white/5";
if (submitted) {
if (isActuallyCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
else if (isWrongSelection) style = "bg-red-500/20 border-red-500 text-red-100";
else if (missedCorrect) style = "border-orange-500/50 text-orange-400 animate-pulse";
else style = "opacity-50 grayscale border-white/5";
} else if (isSelected) {
style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]";
}
return (
<button
key={oIdx}
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
>
<div className="flex items-center justify-between">
<span>{opt}</span>
{submitted && isActuallyCorrect && <span></span>}
{submitted && isWrongSelection && <span></span>}
{submitted && missedCorrect && <span className="text-[10px] uppercase font-black tracking-tighter">Correct Answer</span>}
</div>
</button>
);
})}
</div>
</div>
))}
{!submitted && questions.length > 0 && (
<button
onClick={() => setSubmitted(true)}
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
>
Validate Answers
</button>
)}
{submitted && (
<button
onClick={() => { setSubmitted(false); setUserAnswers({}); }}
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-3xl border-white/5"
>
Try Again
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
interface ShortAnswerPlayerProps {
id: string;
title?: string;
prompt: string;
correctAnswers: string[];
}
export default function ShortAnswerPlayer({ id, title, prompt, correctAnswers }: ShortAnswerPlayerProps) {
const [userAnswer, setUserAnswer] = useState("");
const [submitted, setSubmitted] = useState(false);
const handleReset = () => {
setSubmitted(false);
setUserAnswer("");
};
const isCorrect = (correctAnswers || []).some(ans => ans.trim().toLowerCase() === userAnswer.trim().toLowerCase());
return (
<div className="space-y-8" id={id}>
<div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
{title || "Short Answer"}
</h3>
</div>
<div className="p-8 glass border-white/5 rounded-3xl space-y-8">
<p className="text-xl font-bold text-gray-100">{prompt || "Please enter your answer below:"}</p>
<div className="space-y-4">
<input
type="text"
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value)}
disabled={submitted}
className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted
? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
: "border-white/10 focus:border-blue-500 text-white"
}`}
placeholder="Type your answer..."
/>
{submitted && !isCorrect && (
<div className="p-4 bg-orange-500/10 border border-orange-500/20 rounded-xl animate-in fade-in duration-500">
<p className="text-[10px] text-orange-400 uppercase font-black tracking-widest">Suggested Answer(s):</p>
<p className="text-sm text-gray-400 mt-1">{(correctAnswers || [])[0]}</p>
</div>
)}
</div>
{!submitted && (
<button
onClick={() => setSubmitted(true)}
disabled={!userAnswer.trim()}
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:grayscale"
>
Submit Answer
</button>
)}
{submitted && (
<button
onClick={handleReset}
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
>
Try Again
</button>
)}
</div>
</div>
);
}