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:
2026-04-24 09:59:57 -04:00
parent 4148de5d66
commit e72f479639
32 changed files with 2332 additions and 74 deletions
@@ -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">