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:
@@ -18,6 +18,8 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLUListElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Load session from localStorage on mount
|
||||
@@ -33,6 +35,28 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRef.current?.focus();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
@@ -60,10 +84,13 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-24 right-6 w-14 h-14 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-500/40 flex items-center justify-center hover:scale-110 transition-all z-[100] group"
|
||||
aria-label="Abrir Tutor de IA"
|
||||
aria-expanded="false"
|
||||
aria-controls="ai-tutor-dialog"
|
||||
>
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" aria-hidden="true" />
|
||||
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" aria-hidden="true" />
|
||||
@@ -73,9 +100,12 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
id="ai-tutor-dialog"
|
||||
className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-white/95 dark:bg-black/80 backdrop-blur-2xl border border-black/5 dark:border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden"
|
||||
role="dialog"
|
||||
aria-label="Tutor de IA"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ai-tutor-title"
|
||||
aria-describedby="ai-tutor-description"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-black/5 dark:border-white/5 bg-blue-600/5 dark:bg-blue-600/10 flex items-center justify-between">
|
||||
@@ -84,7 +114,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
<Bot className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-gray-900 dark:text-white uppercase tracking-widest">Tutor de IA</h3>
|
||||
<h3 id="ai-tutor-title" className="text-sm font-black text-gray-900 dark:text-white uppercase tracking-widest">Tutor de IA</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-tighter">En Línea</span>
|
||||
@@ -92,6 +122,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
aria-label="Cerrar Tutor de IA"
|
||||
@@ -104,6 +135,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
<ul
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
>
|
||||
@@ -120,7 +152,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
? 'bg-blue-600 text-white rounded-tr-none'
|
||||
: 'bg-black/5 dark:bg-white/5 text-gray-800 dark:text-gray-200 border border-black/5 dark:border-white/5 rounded-tl-none'
|
||||
}`}
|
||||
aria-label={msg.role === 'user' ? 'Tú dijeste' : 'El tutor dijo'}
|
||||
aria-label={msg.role === 'user' ? 'Tú dijiste' : 'El tutor dijo'}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
@@ -128,7 +160,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
</li>
|
||||
))}
|
||||
{isLoading && (
|
||||
<li className="flex justify-start animate-in fade-in duration-300" aria-busy="true">
|
||||
<li className="flex justify-start animate-in fade-in duration-300" aria-busy="true" aria-live="assertive">
|
||||
<div className="flex gap-2 max-w-[85%]">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-600 dark:text-blue-400 flex items-center justify-center" aria-hidden="true">
|
||||
<Bot size={16} />
|
||||
@@ -146,6 +178,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
<div className="p-4 border-t border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-black/40">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -155,6 +188,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
className="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-600/50 dark:focus:border-blue-500/50 transition-colors placeholder:text-gray-400 dark:placeholder:text-gray-600 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="absolute right-2 top-1.5 p-1.5 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:bg-gray-400 dark:disabled:bg-gray-600 transition-all hover:bg-blue-500"
|
||||
@@ -166,6 +200,9 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
<p className="mt-2 text-[9px] text-gray-500 dark:text-gray-600 font-bold uppercase tracking-widest text-center">
|
||||
IA entrenada con el contenido de esta lección
|
||||
</p>
|
||||
<p id="ai-tutor-description" className="sr-only">
|
||||
El tutor responde solo con contenido de la lección actual.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function AppHeader() {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Array<{ id: string; kind: string; title: string; snippet?: string; url: string; course_title?: string }>>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchActiveIndex, setSearchActiveIndex] = useState(-1);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -63,9 +64,45 @@ export default function AppHeader() {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
setSearchActiveIndex(-1);
|
||||
router.push(url);
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (!searchOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
setSearchOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setSearchOpen(false);
|
||||
setSearchActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchResults.length) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSearchActiveIndex((prev) => (prev + 1) % searchResults.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSearchActiveIndex((prev) => (prev <= 0 ? searchResults.length - 1 : prev - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && searchActiveIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const selected = searchResults[searchActiveIndex];
|
||||
if (selected) {
|
||||
handleSearchSelect(selected.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
course: "Curso",
|
||||
lesson: "Lección",
|
||||
@@ -106,11 +143,23 @@ export default function AppHeader() {
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
id="global-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => { setSearchQuery(e.target.value); setSearchOpen(true); }}
|
||||
onChange={e => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSearchOpen(true);
|
||||
setSearchActiveIndex(-1);
|
||||
}}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Buscar cursos, lecciones..."
|
||||
role="combobox"
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="global-search-results"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={searchActiveIndex >= 0 ? `search-option-${searchActiveIndex}` : undefined}
|
||||
aria-label="Buscar cursos, lecciones, foros y anuncios"
|
||||
className="w-56 lg:w-72 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-2 pl-9 pr-3 text-sm text-slate-700 dark:text-white placeholder-gray-400 focus:outline-none focus:border-blue-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
@@ -121,12 +170,15 @@ export default function AppHeader() {
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">Sin resultados para "{searchQuery}"</div>
|
||||
) : (
|
||||
<ul className="max-h-80 overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
|
||||
{searchResults.map(r => (
|
||||
<li key={`${r.kind}-${r.id}`}>
|
||||
<ul id="global-search-results" role="listbox" className="max-h-80 overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
|
||||
{searchResults.map((r, idx) => (
|
||||
<li key={`${r.kind}-${r.id}`} role="presentation">
|
||||
<button
|
||||
id={`search-option-${idx}`}
|
||||
role="option"
|
||||
aria-selected={searchActiveIndex === idx}
|
||||
onClick={() => handleSearchSelect(r.url)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-blue-50 dark:hover:bg-white/5 transition-colors flex flex-col gap-0.5"
|
||||
className={`w-full px-4 py-3 text-left transition-colors flex flex-col gap-0.5 ${searchActiveIndex === idx ? 'bg-blue-50 dark:bg-white/10' : 'hover:bg-blue-50 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 px-1.5 py-0.5 rounded">
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function ConnectivityBanner() {
|
||||
const [online, setOnline] = useState(true);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOnline(navigator.onLine);
|
||||
|
||||
const onOnline = () => {
|
||||
setOnline(true);
|
||||
setVisible(true);
|
||||
window.setTimeout(() => setVisible(false), 2500);
|
||||
};
|
||||
|
||||
const onOffline = () => {
|
||||
setOnline(false);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
window.addEventListener("online", onOnline);
|
||||
window.addEventListener("offline", onOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", onOnline);
|
||||
window.removeEventListener("offline", onOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!visible && online) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[260] px-4" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div
|
||||
className={`rounded-xl px-4 py-2 text-xs font-bold shadow-lg border ${
|
||||
online
|
||||
? "bg-emerald-600 text-white border-emerald-500"
|
||||
: "bg-amber-500 text-slate-950 border-amber-400"
|
||||
}`}
|
||||
>
|
||||
{online ? "Conexion restablecida" : "Sin conexion: usando contenido en cache"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { lmsApi, OfflineSyncStatus } from "@/lib/api";
|
||||
|
||||
const initialStatus: OfflineSyncStatus = {
|
||||
pending: 0,
|
||||
isFlushing: false,
|
||||
lastSyncAt: null,
|
||||
lastFlushedCount: 0,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
export default function OfflineSyncPanel() {
|
||||
const [status, setStatus] = useState<OfflineSyncStatus>(initialStatus);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(lmsApi.getOfflineSyncStatus());
|
||||
const unsubscribe = lmsApi.subscribeOfflineSync((next) => setStatus(next));
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const lastSyncLabel = useMemo(() => {
|
||||
if (!status.lastSyncAt) return "Aun sin sincronizacion";
|
||||
const date = new Date(status.lastSyncAt);
|
||||
return date.toLocaleString();
|
||||
}, [status.lastSyncAt]);
|
||||
|
||||
const shouldShow = status.pending > 0 || status.isFlushing || expanded;
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="fixed bottom-6 left-6 z-[290] w-[min(360px,92vw)] rounded-2xl border border-slate-300/30 bg-white/95 dark:bg-slate-900/95 backdrop-blur shadow-2xl"
|
||||
aria-label="Estado de sincronizacion offline"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 rounded-2xl"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-widest text-slate-700 dark:text-slate-200">
|
||||
Sync Offline
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300 mt-0.5">
|
||||
{status.isFlushing
|
||||
? "Sincronizando..."
|
||||
: `${status.pending} pendiente${status.pending === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 font-bold">
|
||||
{status.pending}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 border-t border-slate-200/60 dark:border-slate-700/60">
|
||||
<p className="text-[11px] text-slate-600 dark:text-slate-300 mt-3">
|
||||
Ultimo sync: <span className="font-semibold">{lastSyncLabel}</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-600 dark:text-slate-300 mt-1">
|
||||
Ultimo lote enviado: <span className="font-semibold">{status.lastFlushedCount}</span>
|
||||
</p>
|
||||
{status.lastError && status.lastError !== "offline" && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 mt-1 font-semibold">
|
||||
Quedaron eventos pendientes; se reintentara automaticamente.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lmsApi.flushOfflineQueue()}
|
||||
disabled={status.isFlushing}
|
||||
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-xs font-bold hover:bg-blue-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
{status.isFlushing ? "Sincronizando..." : "Sincronizar ahora"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold hover:bg-slate-200 dark:hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
Ocultar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||
};
|
||||
|
||||
export default function PwaInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
|
||||
const onAppInstalled = () => {
|
||||
setIsInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
|
||||
if (isStandalone) {
|
||||
setIsInstalled(true);
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onAppInstalled);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onAppInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!deferredPrompt || isInstalled) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[300] px-4">
|
||||
<div className="rounded-2xl border border-blue-300/40 bg-white/95 dark:bg-slate-900/95 backdrop-blur px-4 py-3 shadow-2xl max-w-md w-[92vw]">
|
||||
<p className="text-xs font-semibold text-slate-700 dark:text-slate-200">
|
||||
Instala OpenCCB para acceso rapido y soporte offline.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!deferredPrompt) return;
|
||||
await deferredPrompt.prompt();
|
||||
const choice = await deferredPrompt.userChoice;
|
||||
if (choice.outcome === "accepted") {
|
||||
setDeferredPrompt(null);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-xs font-bold hover:bg-blue-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
Instalar App
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeferredPrompt(null)}
|
||||
className="px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-bold hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
Ahora no
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { lmsApi } from "@/lib/api";
|
||||
|
||||
export default function PwaRegistration() {
|
||||
useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/sw.js", {
|
||||
scope: "/",
|
||||
});
|
||||
|
||||
// Try to flush queued progress events at startup.
|
||||
lmsApi.flushOfflineQueue().catch(() => undefined);
|
||||
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (!installingWorker) return;
|
||||
|
||||
installingWorker.addEventListener("statechange", () => {
|
||||
if (installingWorker.state === "installed" && navigator.serviceWorker.controller) {
|
||||
installingWorker.postMessage({ type: "SKIP_WAITING" });
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Service worker registration failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onOnline = () => {
|
||||
lmsApi.flushOfflineQueue().catch(() => undefined);
|
||||
};
|
||||
|
||||
window.addEventListener("online", onOnline);
|
||||
|
||||
register();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", onOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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