feat: add PWA support with service worker, offline sync, and connectivity banners
- Added PWA icons for 192x192 and 512x512 resolutions. - Implemented service worker (sw.js) for caching static assets and handling fetch requests. - Created ConnectivityBanner component to notify users of online/offline status. - Developed OfflineSyncPanel component to manage and display offline sync status. - Introduced PwaInstallPrompt component to prompt users for PWA installation. - Added PwaRegistration component to handle service worker registration and online event handling. - Created AdminAiAuditPage for AI audit logs with filtering and review functionality. - Developed AdminDataEthicsPage to display AI data ethics summary and recent events.
This commit is contained in:
@@ -37,6 +37,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
const [handledMarkers, setHandledMarkers] = useState<Set<number>>(new Set());
|
||||
const [lastTime, setLastTime] = useState(0);
|
||||
const [feedback, setFeedback] = useState<{ isCorrect: boolean } | null>(null);
|
||||
const [a11yStatus, setA11yStatus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPlayCount !== undefined) {
|
||||
@@ -83,6 +84,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
if (!hasStarted) {
|
||||
setPlayCount(prev => prev + 1);
|
||||
setHasStarted(true);
|
||||
setA11yStatus("Reproducción iniciada.");
|
||||
if (lessonId) {
|
||||
await lmsApi.recordInteraction(lessonId, {
|
||||
video_timestamp: 0,
|
||||
@@ -97,6 +99,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
if (locked) {
|
||||
return (
|
||||
<div className="space-y-4" id={id}>
|
||||
<p className="sr-only" aria-live="polite" aria-atomic="true">Contenido bloqueado por límite de reproducciones.</p>
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">{title || "Contenido Multimedia"}</h3>
|
||||
<div className="glass-card aspect-video flex flex-col items-center justify-center gap-6 border-red-500/10 dark: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-600 dark:text-red-500">
|
||||
@@ -141,6 +144,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
|
||||
return (
|
||||
<div className="space-y-6" id={id}>
|
||||
<p className="sr-only" aria-live="polite" aria-atomic="true">{a11yStatus}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">{title || "Contenido Multimedia"}</h3>
|
||||
{maxPlays > 0 && (
|
||||
@@ -163,6 +167,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
controls
|
||||
crossOrigin="anonymous"
|
||||
className="w-full h-full rounded-xl"
|
||||
aria-label={title || "Reproductor de video"}
|
||||
onPlay={handlePlay}
|
||||
onTimeUpdate={(e) => {
|
||||
const time = e.currentTarget.currentTime;
|
||||
@@ -178,6 +183,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
if (time >= marker.timestamp && lastTime < marker.timestamp && !handledMarkers.has(marker.timestamp)) {
|
||||
e.currentTarget.pause();
|
||||
setActiveMarker(marker);
|
||||
setA11yStatus("Pausa por pregunta interactiva.");
|
||||
setHandledMarkers(prev => new Set(prev).add(marker.timestamp));
|
||||
break;
|
||||
}
|
||||
@@ -201,20 +207,23 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
<iframe
|
||||
src={getEmbedUrl(url)}
|
||||
className="w-full h-full rounded-xl"
|
||||
title={title || "Contenido embebido"}
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Simulated play tracker overlay for iframes (invisible but catches first click) */}
|
||||
{!isLocalFile && playCount === 0 && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
className="absolute inset-0 bg-white/40 dark:bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-white/20 dark:group-hover:bg-black/20 transition-all"
|
||||
className="absolute inset-0 bg-white/40 dark:bg-black/40 flex items-center justify-center cursor-pointer group-hover:bg-white/20 dark:group-hover:bg-black/20 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
aria-label="Iniciar reproducción del contenido"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-full bg-blue-600 dark: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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -227,7 +236,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
{/* Question Overlay */}
|
||||
{activeMarker && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-white/60 dark:bg-black/60 backdrop-blur-md rounded-xl animate-in fade-in duration-300">
|
||||
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-6 rounded-2xl shadow-2xl max-w-sm w-full space-y-4 border border-black/5 dark:border-white/5">
|
||||
<div role="dialog" aria-modal="true" aria-label="Pregunta interactiva del video" className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-6 rounded-2xl shadow-2xl max-w-sm w-full space-y-4 border border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center gap-2 text-blue-600 font-bold text-xs uppercase tracking-widest">
|
||||
<AlertCircle size={16} />
|
||||
<span>Quick Check</span>
|
||||
@@ -237,6 +246,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
{activeMarker.options.map((option, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
disabled={!!feedback}
|
||||
onClick={() => {
|
||||
const isCorrect = idx === activeMarker.correctIndex;
|
||||
@@ -244,6 +254,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
if (isGraded) {
|
||||
// Graded Mode: Show feedback then continue
|
||||
setFeedback({ isCorrect });
|
||||
setA11yStatus(isCorrect ? "Respuesta correcta." : "Respuesta incorrecta.");
|
||||
// Save answer to backend (mocked for now)
|
||||
console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`);
|
||||
|
||||
@@ -257,6 +268,7 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
// Formative Mode: Block until correct
|
||||
if (isCorrect) {
|
||||
setFeedback({ isCorrect: true });
|
||||
setA11yStatus("Respuesta correcta.");
|
||||
setTimeout(() => {
|
||||
setFeedback(null);
|
||||
setActiveMarker(null);
|
||||
@@ -265,12 +277,13 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
}, 1000);
|
||||
} else {
|
||||
setFeedback({ isCorrect: false });
|
||||
setA11yStatus("Respuesta incorrecta. Intenta nuevamente.");
|
||||
alert("Try again! (This is just practice)");
|
||||
setFeedback(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`px-4 py-3 rounded-xl font-medium transition-all text-left ${feedback
|
||||
className={`px-4 py-3 rounded-xl font-medium transition-all text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${feedback
|
||||
? idx === activeMarker.correctIndex
|
||||
? "bg-green-600 dark:bg-green-500 text-white"
|
||||
: feedback.isCorrect === false && "bg-red-600 dark:bg-red-500 text-white"
|
||||
|
||||
@@ -20,6 +20,7 @@ interface QuizPlayerProps {
|
||||
title?: string;
|
||||
quizData: {
|
||||
questions: QuizQuestion[];
|
||||
test_type?: string;
|
||||
instructions?: string;
|
||||
passing_score?: number;
|
||||
total_points?: number;
|
||||
@@ -56,6 +57,7 @@ export default function QuizPlayer({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [score, setScore] = useState<number | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [a11yStatus, setA11yStatus] = useState("");
|
||||
|
||||
const questions = quizData?.questions || [];
|
||||
const isSingleAttempt = maxAttempts === 1 || quizData.max_attempts === 1;
|
||||
@@ -118,11 +120,13 @@ export default function QuizPlayer({
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (maxAttempts > 0 && attempts >= maxAttempts) return;
|
||||
setA11yStatus("Enviando respuestas del cuestionario.");
|
||||
|
||||
// Check if all questions are answered
|
||||
const allAnswered = questions.every(q => userAnswers[q.id] && userAnswers[q.id].length > 0);
|
||||
if (!allAnswered) {
|
||||
if (!confirm('Hay preguntas sin responder. ¿Estás seguro de que deseas enviar?')) {
|
||||
setA11yStatus("Envío cancelado. Puedes completar las preguntas pendientes.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -150,11 +154,9 @@ export default function QuizPlayer({
|
||||
quiz_type: quizData.test_type || 'quiz',
|
||||
};
|
||||
|
||||
// Submit to LMS API
|
||||
await lmsApi.submitScore(userId, courseId, lessonId, calculatedScore, answersMetadata);
|
||||
|
||||
setSubmitted(true);
|
||||
setAttempts(prev => prev + 1);
|
||||
setA11yStatus(`Prueba enviada correctamente. Puntuación ${scorePercent} por ciento.`);
|
||||
|
||||
if (onAttempt) {
|
||||
onAttempt(calculatedScore, answersMetadata);
|
||||
@@ -164,6 +166,7 @@ export default function QuizPlayer({
|
||||
alert(`¡Prueba enviada! Tu puntuación: ${scorePercent}%`);
|
||||
} catch (error) {
|
||||
console.error('Error submitting quiz:', error);
|
||||
setA11yStatus('Error al enviar la prueba.');
|
||||
alert('Error al enviar la prueba. Por favor, inténtalo de nuevo.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -301,6 +304,7 @@ export default function QuizPlayer({
|
||||
// Normal quiz mode (not yet submitted or allows retry)
|
||||
return (
|
||||
<div className="space-y-8 notranslate" id={id} translate="no">
|
||||
<p className="sr-only" aria-live="polite" aria-atomic="true">{a11yStatus}</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-bold border-l-4 border-blue-600 dark:border-blue-500 pl-4 py-1 tracking-tight text-gray-900 dark:text-white uppercase tracking-widest text-[10px]">
|
||||
@@ -345,7 +349,8 @@ export default function QuizPlayer({
|
||||
>
|
||||
{q.options.map((opt, oIdx) => {
|
||||
const isSelected = userAnswers[q.id]?.includes(oIdx);
|
||||
const isCorrect = q.correct?.includes(oIdx);
|
||||
const correctAnswers = Array.isArray(q.correct) ? q.correct : [q.correct];
|
||||
const isCorrect = correctAnswers.includes(oIdx);
|
||||
const isActuallyCorrect = isCorrect && isSelected;
|
||||
const isWrongSelection = !isCorrect && isSelected;
|
||||
const missedCorrect = isCorrect && !isSelected;
|
||||
@@ -367,8 +372,9 @@ export default function QuizPlayer({
|
||||
role={q.type === 'multiple-select' ? 'checkbox' : 'radio'}
|
||||
aria-checked={isSelected}
|
||||
aria-disabled={submitted || submitting}
|
||||
aria-label={`Opción ${oIdx + 1}: ${opt}`}
|
||||
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
|
||||
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus:ring-2 focus:ring-blue-500/50 disabled:cursor-not-allowed ${style}`}
|
||||
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 disabled:cursor-not-allowed ${style}`}
|
||||
disabled={submitted || submitting}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -404,9 +410,10 @@ export default function QuizPlayer({
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={submitting || (maxAttempts > 0 && attempts >= maxAttempts)}
|
||||
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:cursor-not-allowed ${
|
||||
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:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${
|
||||
isSingleAttempt ? 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800' : ''
|
||||
}`}
|
||||
aria-describedby={`quiz-attempts-${id}`}
|
||||
>
|
||||
{submitting ? 'Enviando...' : (
|
||||
isSingleAttempt
|
||||
@@ -417,6 +424,9 @@ export default function QuizPlayer({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<p id={`quiz-attempts-${id}`} className="sr-only">
|
||||
Intentos usados: {attempts}. Máximo permitido: {maxAttempts > 0 ? maxAttempts : 'sin límite'}.
|
||||
</p>
|
||||
|
||||
{submitted && score !== null && (
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 border-2 border-green-300 dark:border-green-700 rounded-2xl p-6 text-center">
|
||||
|
||||
Reference in New Issue
Block a user