feat: Implement AI tutor functionality, add branding fields, and improve API URL handling.
This commit is contained in:
@@ -236,7 +236,7 @@ export default function ExperienceLoginPage() {
|
||||
<div className="mt-6 pt-6 border-t border-white/10 text-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
¿Eres un instructor?{" "}
|
||||
<a href="http://localhost:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
|
||||
<a href="http://192.168.0.254:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
|
||||
Ir al Portal de Instructores
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -19,6 +19,8 @@ import MemoryPlayer from "@/components/blocks/MemoryPlayer";
|
||||
import DocumentPlayer from "@/components/blocks/DocumentPlayer";
|
||||
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
|
||||
import InteractiveTranscript from "@/components/InteractiveTranscript";
|
||||
import AITutor from "@/components/AITutor";
|
||||
import LessonLockedView from "@/components/LessonLockedView";
|
||||
import { ListMusic } from "lucide-react";
|
||||
|
||||
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
|
||||
@@ -188,179 +190,178 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render Blocks */}
|
||||
{(lesson.metadata?.blocks || []).length > 0 ? (
|
||||
<div className="space-y-24">
|
||||
{lesson.metadata?.blocks?.map((block) => {
|
||||
const renderBlock = () => {
|
||||
switch (block.type) {
|
||||
case 'description':
|
||||
return <DescriptionPlayer id={block.id} title={block.title} content={block.content || ""} />;
|
||||
case 'media':
|
||||
return (
|
||||
<MediaPlayer
|
||||
id={block.id}
|
||||
lessonId={params.lessonId}
|
||||
title={block.title}
|
||||
url={block.url || ""}
|
||||
media_type={block.media_type || 'video'}
|
||||
config={block.config}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
initialPlayCount={
|
||||
userGrade?.metadata?.play_counts
|
||||
? (userGrade.metadata.play_counts as Record<string, number>)[block.id] || 0
|
||||
: 0
|
||||
}
|
||||
onPlay={async () => {
|
||||
if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) {
|
||||
const currentPlayCounts = (userGrade?.metadata?.play_counts as Record<string, number>) || {};
|
||||
const newPlayCounts = {
|
||||
...currentPlayCounts,
|
||||
[block.id]: (currentPlayCounts[block.id] || 0) + 1
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await lmsApi.submitScore(
|
||||
user.id,
|
||||
params.id,
|
||||
params.lessonId,
|
||||
userGrade?.score || 0,
|
||||
{ ...userGrade?.metadata, play_counts: newPlayCounts }
|
||||
);
|
||||
setUserGrade(res);
|
||||
} catch (err) {
|
||||
console.error("Error al guardar el recuento de reproducciones", err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
isGraded={lesson.is_graded}
|
||||
hasTranscription={!!lesson.transcription}
|
||||
/>
|
||||
);
|
||||
case 'document':
|
||||
return <DocumentPlayer id={block.id} title={block.title} url={block.url || ""} />;
|
||||
case 'quiz':
|
||||
return (
|
||||
<QuizPlayer
|
||||
id={block.id}
|
||||
title={block.title}
|
||||
quizData={block.quiz_data || { questions: [] }}
|
||||
allowRetry={lesson.allow_retry}
|
||||
maxAttempts={lesson.max_attempts || undefined}
|
||||
initialAttempts={
|
||||
userGrade?.metadata?.block_attempts
|
||||
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
|
||||
: 0
|
||||
}
|
||||
onAttempt={async () => {
|
||||
if (user) {
|
||||
const currentAttempts = (userGrade?.metadata?.block_attempts as Record<string, number>) || {};
|
||||
const newAttempts = {
|
||||
...currentAttempts,
|
||||
[block.id]: (currentAttempts[block.id] || 0) + 1
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await lmsApi.submitScore(
|
||||
user.id,
|
||||
params.id,
|
||||
params.lessonId,
|
||||
userGrade?.score || 0,
|
||||
{ ...userGrade?.metadata, block_attempts: newAttempts }
|
||||
);
|
||||
setUserGrade(res);
|
||||
} catch (err) {
|
||||
console.error("Error al guardar los intentos del bloque", err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'fill-in-the-blanks':
|
||||
return <FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} />;
|
||||
case 'matching':
|
||||
return <MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} allowRetry={lesson.allow_retry} />;
|
||||
case 'ordering':
|
||||
return <OrderingPlayer id={block.id} title={block.title} items={block.items || []} allowRetry={lesson.allow_retry} />;
|
||||
case 'short-answer':
|
||||
return (
|
||||
<ShortAnswerPlayer
|
||||
id={block.id}
|
||||
title={block.title}
|
||||
prompt={block.prompt || ""}
|
||||
correctAnswers={block.correctAnswers || []}
|
||||
allowRetry={lesson.allow_retry}
|
||||
/>
|
||||
);
|
||||
case 'audio-response':
|
||||
return (
|
||||
<AudioResponsePlayer
|
||||
id={block.id}
|
||||
prompt={block.prompt || ""}
|
||||
keywords={block.keywords}
|
||||
timeLimit={block.timeLimit}
|
||||
isGraded={lesson.is_graded}
|
||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||
/>
|
||||
);
|
||||
case 'code':
|
||||
return (
|
||||
<CodeExercisePlayer
|
||||
title={block.title}
|
||||
instructions={block.instructions || ""}
|
||||
initialCode={block.initialCode || ""}
|
||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||
/>
|
||||
);
|
||||
case 'hotspot':
|
||||
return (
|
||||
<HotspotPlayer
|
||||
title={block.title}
|
||||
description={block.description || ""}
|
||||
imageUrl={block.imageUrl || ""}
|
||||
hotspots={block.hotspots || []}
|
||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||
/>
|
||||
);
|
||||
case 'memory-match':
|
||||
return (
|
||||
<MemoryPlayer
|
||||
title={block.title}
|
||||
pairs={block.pairs || []}
|
||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div className="p-4 bg-white/5 border border-white/10 rounded-xl text-xs font-bold text-gray-500 uppercase tracking-widest">Tipo de Bloque Desconocido: {block.type}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={block.id} className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
|
||||
{renderBlock()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Render Blocks or Locked View */}
|
||||
{lesson.is_graded && userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? (
|
||||
<LessonLockedView
|
||||
lessonId={params.lessonId}
|
||||
courseId={params.id}
|
||||
grade={userGrade}
|
||||
maxAttempts={lesson.max_attempts}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-20 text-center glass-card border-dashed border-white/10">
|
||||
<p className="text-gray-500 font-bold uppercase tracking-widest">Actualmente, esta lección no tiene contenido.</p>
|
||||
</div>
|
||||
(lesson.metadata?.blocks || []).length > 0 ? (
|
||||
<div className="space-y-24">
|
||||
{lesson.metadata?.blocks?.map((block) => {
|
||||
const renderBlock = () => {
|
||||
switch (block.type) {
|
||||
case 'description':
|
||||
return <DescriptionPlayer id={block.id} title={block.title} content={block.content || ""} />;
|
||||
case 'media':
|
||||
return (
|
||||
<MediaPlayer
|
||||
id={block.id}
|
||||
lessonId={params.lessonId}
|
||||
title={block.title}
|
||||
url={block.url || ""}
|
||||
media_type={block.media_type || 'video'}
|
||||
config={block.config}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
initialPlayCount={
|
||||
userGrade?.metadata?.play_counts
|
||||
? (userGrade.metadata.play_counts as Record<string, number>)[block.id] || 0
|
||||
: 0
|
||||
}
|
||||
onPlay={async () => {
|
||||
if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) {
|
||||
const currentPlayCounts = (userGrade?.metadata?.play_counts as Record<string, number>) || {};
|
||||
const newPlayCounts = {
|
||||
...currentPlayCounts,
|
||||
[block.id]: (currentPlayCounts[block.id] || 0) + 1
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await lmsApi.submitScore(
|
||||
user.id,
|
||||
params.id,
|
||||
params.lessonId,
|
||||
userGrade?.score || 0,
|
||||
{ ...userGrade?.metadata, play_counts: newPlayCounts }
|
||||
);
|
||||
setUserGrade(res);
|
||||
} catch (err) {
|
||||
console.error("Error al guardar el recuento de reproducciones", err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
isGraded={lesson.is_graded}
|
||||
hasTranscription={!!lesson.transcription}
|
||||
/>
|
||||
);
|
||||
case 'document':
|
||||
return <DocumentPlayer id={block.id} title={block.title} url={block.url || ""} />;
|
||||
case 'quiz':
|
||||
return (
|
||||
<QuizPlayer
|
||||
id={block.id}
|
||||
title={block.title}
|
||||
quizData={block.quiz_data || { questions: [] }}
|
||||
allowRetry={lesson.allow_retry}
|
||||
maxAttempts={lesson.max_attempts || undefined}
|
||||
initialAttempts={
|
||||
userGrade?.metadata?.block_attempts
|
||||
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
|
||||
: 0
|
||||
}
|
||||
onAttempt={async () => {
|
||||
if (user) {
|
||||
const currentAttempts = (userGrade?.metadata?.block_attempts as Record<string, number>) || {};
|
||||
const newAttempts = {
|
||||
...currentAttempts,
|
||||
[block.id]: (currentAttempts[block.id] || 0) + 1
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await lmsApi.submitScore(
|
||||
user.id,
|
||||
params.id,
|
||||
params.lessonId,
|
||||
userGrade?.score || 0,
|
||||
{ ...userGrade?.metadata, block_attempts: newAttempts }
|
||||
);
|
||||
setUserGrade(res);
|
||||
} catch (err) {
|
||||
console.error("Error al guardar los intentos del bloque", err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'fill-in-the-blanks':
|
||||
return <FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} />;
|
||||
case 'matching':
|
||||
return <MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} allowRetry={lesson.allow_retry} />;
|
||||
case 'ordering':
|
||||
return <OrderingPlayer id={block.id} title={block.title} items={block.items || []} allowRetry={lesson.allow_retry} />;
|
||||
case 'short-answer':
|
||||
return (
|
||||
<ShortAnswerPlayer
|
||||
id={block.id}
|
||||
title={block.title}
|
||||
prompt={block.prompt || ""}
|
||||
correctAnswers={block.correctAnswers || []}
|
||||
allowRetry={lesson.allow_retry}
|
||||
/>
|
||||
);
|
||||
case 'audio-response':
|
||||
return (
|
||||
<AudioResponsePlayer
|
||||
id={block.id}
|
||||
prompt={block.prompt || ""}
|
||||
keywords={block.keywords}
|
||||
timeLimit={block.timeLimit}
|
||||
isGraded={lesson.is_graded}
|
||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||
/>
|
||||
);
|
||||
case 'code':
|
||||
return (
|
||||
<CodeExercisePlayer
|
||||
title={block.title}
|
||||
instructions={block.instructions || ""}
|
||||
initialCode={block.initialCode || ""}
|
||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||
/>
|
||||
);
|
||||
case 'hotspot':
|
||||
return (
|
||||
<HotspotPlayer
|
||||
title={block.title}
|
||||
description={block.description || ""}
|
||||
imageUrl={block.imageUrl || ""}
|
||||
hotspots={block.hotspots || []}
|
||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||
/>
|
||||
);
|
||||
case 'memory-match':
|
||||
return (
|
||||
<MemoryPlayer
|
||||
title={block.title}
|
||||
pairs={block.pairs || []}
|
||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div className="p-4 bg-white/5 border border-white/10 rounded-xl text-xs font-bold text-gray-500 uppercase tracking-widest">Tipo de Bloque Desconocido: {block.type}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={block.id} className="animate-in fade-in slide-in-from-bottom-6 duration-700 delay-100">
|
||||
{renderBlock()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-20 text-center glass-card border-dashed border-white/10">
|
||||
<p className="text-gray-500 font-bold uppercase tracking-widest">Actualmente, esta lección no tiene contenido.</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{lesson.is_graded && (
|
||||
<div className="pt-20 border-t border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
||||
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? (
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex items-center gap-2 px-6 py-2 bg-amber-500/10 border border-amber-500/30 text-amber-400 rounded-full text-xs font-black uppercase tracking-widest">
|
||||
Bloqueado: Se alcanzó el máximo de intentos ({lesson.max_attempts})
|
||||
</div>
|
||||
<div className="text-4xl font-black text-white">
|
||||
Puntuación: <span className="text-blue-500">{userGrade.score * 100}%</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs italic">Esta evaluación ya está cerrada para futuras entregas.</p>
|
||||
</div>
|
||||
) : (
|
||||
{userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? null : (
|
||||
<>
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -442,6 +443,9 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
</Link>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{/* AI Tutor Bubble/Panel */}
|
||||
<AITutor lessonId={params.lessonId} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ body {
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@apply glass rounded-2xl p-6 transition-all duration-300;
|
||||
@apply glass rounded-2xl p-4 md:p-6 transition-all duration-300;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
@@ -88,4 +88,4 @@ body {
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export default function MyLearningPage() {
|
||||
enrichedEnrollments.push({
|
||||
course: { ...course, modules },
|
||||
progress,
|
||||
lastAccessed: enrollment.enroled_at
|
||||
lastAccessed: enrollment.enrolled_at
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error loading course ${enrollment.course_id}`, err);
|
||||
|
||||
@@ -85,22 +85,22 @@ export default function CatalogPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-20">
|
||||
<div className="mb-20 flex flex-col md:flex-row md:items-end justify-between gap-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 md:py-20">
|
||||
<div className="mb-12 md:mb-20 flex flex-col md:flex-row md:items-end justify-between gap-8 text-center md:text-left">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-blue-500">
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-blue-500">
|
||||
<Star size={14} className="fill-blue-500" />
|
||||
<span>Currículo Premier</span>
|
||||
</div>
|
||||
<h1 className="text-6xl font-black tracking-tighter leading-none">
|
||||
Explorar <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-indigo-600">Cursos</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black tracking-tighter leading-tight md:leading-none">
|
||||
Explorar <span className="block sm:inline text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-indigo-600">Cursos</span>
|
||||
</h1>
|
||||
<p className="text-gray-500 font-medium max-w-xl text-lg">
|
||||
<p className="text-gray-500 font-medium max-w-xl text-base md:text-lg mx-auto md:mx-0">
|
||||
Domina las habilidades del futuro con nuestro contenido educativo de alta fidelidad.
|
||||
</p>
|
||||
</div>
|
||||
{!user && (
|
||||
<Link href="/auth/register" className="btn-premium !bg-white !text-black shadow-none !px-8">
|
||||
<Link href="/auth/register" className="btn-premium !bg-white !text-black shadow-none !px-8 w-full sm:w-auto">
|
||||
Comienza Gratis
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from "next/image";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useTranslation } from "@/context/I18nContext";
|
||||
import { lmsApi, CMS_API_URL } from "@/lib/api";
|
||||
import { lmsApi, getCmsApiUrl } from "@/lib/api";
|
||||
import {
|
||||
Save,
|
||||
Shield,
|
||||
@@ -60,7 +60,7 @@ export default function ProfilePage() {
|
||||
if (path.startsWith('http')) return path;
|
||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||
return `${CMS_API_URL}${finalPath}`;
|
||||
return `${getCmsApiUrl()}${finalPath}`;
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { lmsApi } from "@/lib/api";
|
||||
import { Send, Bot, User, X, MessageSquare, Loader2 } from "lucide-react";
|
||||
|
||||
interface Message {
|
||||
role: 'tutor' | 'user';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: 'tutor', content: '¡Hola! Soy tu tutor de IA. ¿Tienes alguna duda sobre esta lección?' }
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
setInput("");
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { response } = await lmsApi.chatWithTutor(lessonId, userMessage);
|
||||
setMessages(prev => [...prev, { role: 'tutor', content: response }]);
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error);
|
||||
setMessages(prev => [...prev, { role: 'tutor', content: "Lo siento, hubo un error conectando con el tutor. Por favor intenta de nuevo." }]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<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"
|
||||
title="Abrir Tutor de IA"
|
||||
>
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" />
|
||||
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-black/80 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-white/5 bg-blue-600/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<Bot className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black 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-400 uppercase tracking-tighter">En Línea</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-white/5 rounded-lg text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide"
|
||||
>
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`flex gap-2 max-w-[85%] ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<div className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${msg.role === 'user' ? 'bg-white/5' : 'bg-blue-600/20 text-blue-400'}`}>
|
||||
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
|
||||
</div>
|
||||
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed ${msg.role === 'user'
|
||||
? 'bg-blue-600 text-white rounded-tr-none'
|
||||
: 'bg-white/5 text-gray-200 border border-white/5 rounded-tl-none'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start animate-in fade-in duration-300">
|
||||
<div className="flex gap-2 max-w-[85%]">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-400 flex items-center justify-center">
|
||||
<Bot size={16} />
|
||||
</div>
|
||||
<div className="bg-white/5 text-gray-400 border border-white/5 p-3 rounded-2xl rounded-tl-none flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">El tutor está pensando...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-white/5 bg-black/40">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder="Escribe tu duda aquí..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-500/50 transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
<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-600 transition-all hover:bg-blue-500"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[9px] text-gray-600 font-bold uppercase tracking-widest text-center">
|
||||
IA entrenada con el contenido de esta lección
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import Image from "next/image";
|
||||
import { useBranding } from "@/context/BrandingContext";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useTranslation } from "@/context/I18nContext";
|
||||
import { LogOut, Globe } from "lucide-react";
|
||||
import { LogOut, Globe, Menu, X } from "lucide-react";
|
||||
import NotificationCenter from "./NotificationCenter";
|
||||
import { useState } from "react";
|
||||
|
||||
import { lmsApi, getImageUrl } from "@/lib/api";
|
||||
|
||||
@@ -14,14 +15,15 @@ export default function AppHeader() {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { branding } = useBranding();
|
||||
const { user, logout } = useAuth();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// Use platform_name if available, otherwise name, otherwise default
|
||||
const platformName = branding?.platform_name || branding?.name || 'OpenCCB';
|
||||
|
||||
return (
|
||||
<header className="h-16 glass sticky top-0 z-50 px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
|
||||
<header className="h-16 glass sticky top-0 z-[100] px-4 md:px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
|
||||
<Link href="/" className="flex items-center gap-2 md:gap-3 group">
|
||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-lg md:rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
|
||||
{branding?.logo_url ? (
|
||||
<Image src={getImageUrl(branding.logo_url)} alt={branding.name} fill className="object-contain" sizes="40px" />
|
||||
) : (
|
||||
@@ -31,53 +33,129 @@ export default function AppHeader() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col -gap-1">
|
||||
<span className="font-black text-lg tracking-tighter text-white leading-none">
|
||||
<span className="font-black text-sm md:text-lg tracking-tighter text-white leading-none">
|
||||
{platformName.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] font-black tracking-widest text-blue-500 uppercase">EXPERIENCIA</span>
|
||||
<span className="text-[8px] md:text-[10px] font-black tracking-widest text-blue-500 uppercase">EXPERIENCIA</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-2 md:gap-8">
|
||||
<div className="hidden md:flex items-center gap-8 mr-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="hidden md:flex items-center gap-8 mr-4">
|
||||
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
|
||||
{t('nav.catalog')}
|
||||
</Link>
|
||||
<Link href="/my-learning" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
|
||||
{t('nav.myLearning')}
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<NotificationCenter />
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<NotificationCenter />
|
||||
|
||||
<div className="flex items-center gap-2 border-l border-white/10 pl-4">
|
||||
<Globe size={14} className="text-gray-500" />
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="bg-transparent text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="en" className="bg-[#0f1115]">EN</option>
|
||||
<option value="es" className="bg-[#0f1115]">ES</option>
|
||||
<option value="pt" className="bg-[#0f1115]">PT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-2 border-l border-white/10 pl-4">
|
||||
<Globe size={14} className="text-gray-500" />
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="bg-transparent text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="en" className="bg-[#0f1115]">EN</option>
|
||||
<option value="es" className="bg-[#0f1115]">ES</option>
|
||||
<option value="pt" className="bg-[#0f1115]">PT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-4 pl-4 border-l border-white/10">
|
||||
<Link href="/profile" className="flex items-center gap-2 group/profile">
|
||||
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10 flex items-center justify-center font-bold text-xs text-blue-400 group-hover/profile:border-blue-500/50 transition-colors">
|
||||
{user?.full_name?.charAt(0) || 'U'}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-4 pl-4 border-l border-white/10">
|
||||
<Link href="/profile" className="flex items-center gap-2 group/profile">
|
||||
<div className="w-8 h-8 rounded-full bg-white/5 border border-white/10 flex items-center justify-center font-bold text-xs text-blue-400 group-hover/profile:border-blue-500/50 transition-colors">
|
||||
{user?.full_name?.charAt(0) || 'U'}
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 hover:bg-red-500/10 rounded-full text-gray-400 hover:text-red-400 transition-colors"
|
||||
title={t('nav.signOut')}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 hover:bg-red-500/10 rounded-full text-gray-400 hover:text-red-400 transition-colors"
|
||||
title={t('nav.signOut')}
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="md:hidden p-2 hover:bg-white/5 rounded-lg text-gray-400 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isMenuOpen && (
|
||||
<div className="fixed inset-0 z-[150] md:hidden bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="absolute right-0 top-0 bottom-0 w-64 glass border-l border-white/10 p-6 flex flex-col animate-in slide-in-from-right duration-300">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<span className="font-black text-xs uppercase tracking-[0.2em] text-gray-500">Menú</span>
|
||||
<button onClick={() => setIsMenuOpen(false)} className="p-2 hover:bg-white/5 rounded-lg">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-6 flex-1">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="text-sm font-black uppercase tracking-widest text-gray-300 hover:text-white border-l-2 border-transparent hover:border-blue-500 pl-4 transition-all"
|
||||
>
|
||||
{t('nav.catalog')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/my-learning"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="text-sm font-black uppercase tracking-widest text-gray-300 hover:text-white border-l-2 border-transparent hover:border-blue-500 pl-4 transition-all"
|
||||
>
|
||||
{t('nav.myLearning')}
|
||||
</Link>
|
||||
|
||||
<div className="pt-6 mt-6 border-t border-white/5 space-y-4">
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-xl bg-white/5">
|
||||
<Globe size={16} className="text-gray-500" />
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="bg-transparent text-xs font-bold uppercase tracking-widest text-gray-300 focus:outline-none flex-1"
|
||||
>
|
||||
<option value="en" className="bg-[#0f1115]">English</option>
|
||||
<option value="es" className="bg-[#0f1115]">Español</option>
|
||||
<option value="pt" className="bg-[#0f1115]">Português</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="pt-6 border-t border-white/5 space-y-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center font-bold text-xs text-blue-400">
|
||||
{user?.full_name?.charAt(0) || 'U'}
|
||||
</div>
|
||||
<span className="text-sm font-bold">{user?.full_name || 'Mi Perfil'}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span className="text-sm font-bold">{t('nav.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { lmsApi, UserGrade } from "@/lib/api";
|
||||
import { Trophy, Award, BookOpen, RotateCcw, Bot, Loader2, Star, Sparkles, CheckCircle2 } from "lucide-react";
|
||||
|
||||
interface LessonLockedViewProps {
|
||||
lessonId: string;
|
||||
courseId: string;
|
||||
grade: UserGrade;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export default function LessonLockedView({ lessonId, courseId, grade, maxAttempts }: LessonLockedViewProps) {
|
||||
const [feedback, setFeedback] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeedback = async () => {
|
||||
try {
|
||||
const res = await lmsApi.getLessonFeedback(lessonId);
|
||||
setFeedback(res.response);
|
||||
} catch (err) {
|
||||
console.error("Error fetching AI feedback:", err);
|
||||
setFeedback("¡Buen trabajo completando esta evaluación! Sigue así para mejorar tus resultados en las próximas lecciones.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFeedback();
|
||||
}, [lessonId]);
|
||||
|
||||
const scorePct = Math.round(grade.score * 100);
|
||||
const isPassing = scorePct >= 70; // Assuming 70% is passing
|
||||
|
||||
return (
|
||||
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
||||
{/* Header / Score Card */}
|
||||
<div className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-[3rem] blur opacity-25 group-hover:opacity-40 transition duration-1000"></div>
|
||||
<div className="relative glass p-10 md:p-16 rounded-[2.5rem] border border-white/10 bg-black/40 text-center space-y-8 overflow-hidden">
|
||||
{/* Background visual flair */}
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10">
|
||||
<Sparkles size={120} className="text-blue-500" />
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 px-6 py-2 bg-blue-600/10 border border-blue-500/20 text-blue-400 rounded-full text-[10px] font-black uppercase tracking-[0.2em]">
|
||||
<CheckCircle2 size={12} /> Evaluación Finalizada
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-5xl md:text-7xl font-black text-white tracking-tighter">
|
||||
Tu Puntuación: <span className={isPassing ? "text-blue-500" : "text-amber-500"}>{scorePct}%</span>
|
||||
</h2>
|
||||
<div className="flex justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<Star
|
||||
key={s}
|
||||
size={32}
|
||||
className={`${s <= Math.ceil(scorePct / 20) ? "text-yellow-500 fill-yellow-500" : "text-white/5"} transition-all`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6 pt-4">
|
||||
<div className="flex items-center gap-3 px-6 py-4 rounded-2xl bg-white/5 border border-white/5">
|
||||
<RotateCcw size={20} className="text-gray-500" />
|
||||
<div className="text-left">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Intentos Usados</p>
|
||||
<p className="text-lg font-black text-white">{grade.attempts_count} {maxAttempts ? `de ${maxAttempts}` : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-6 py-4 rounded-2xl bg-white/5 border border-white/5">
|
||||
<Trophy size={20} className="text-amber-500" />
|
||||
<div className="text-left">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Estado</p>
|
||||
<p className={`text-lg font-black uppercase ${isPassing ? "text-green-500" : "text-amber-500"}`}>
|
||||
{isPassing ? "Aprobado" : "No Alcanzado"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Feedback Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="glass p-8 md:p-12 rounded-[2.5rem] border border-white/5 bg-blue-600/5 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-blue-600/10 blur-[100px] rounded-full group-hover:bg-blue-600/20 transition-all duration-1000"></div>
|
||||
|
||||
<h3 className="text-xs font-black uppercase tracking-[0.3em] text-blue-400 mb-8 flex items-center gap-3">
|
||||
<Bot size={20} className="animate-bounce" /> Retroalimentación de tu Tutor de IA
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin" />
|
||||
<p className="text-xs font-black uppercase tracking-widest text-gray-500">Generando análisis personalizado...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="text-xl md:text-2xl text-gray-200 leading-relaxed font-medium">
|
||||
{feedback}
|
||||
</div>
|
||||
<div className="h-px w-20 bg-blue-500/40 rounded-full"></div>
|
||||
<p className="text-xs font-bold text-gray-500 italic uppercase tracking-wider">
|
||||
Este análisis es generado automáticamente basándose en tu desempeño histórico y los contenidos de esta lección.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div className="glass p-8 rounded-[2rem] border border-white/5 bg-white/[0.02]">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
|
||||
<BookOpen size={16} className="text-blue-500" /> Próximos Pasos
|
||||
</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-3 p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-colors border border-transparent hover:border-white/10 group cursor-pointer">
|
||||
<div className="w-8 h-8 rounded-lg bg-green-500/20 text-green-400 flex items-center justify-center shrink-0">
|
||||
<Award size={16} />
|
||||
</div>
|
||||
<p className="text-xs font-bold text-gray-300 leading-tight group-hover:text-white transition-colors">Continúa con la siguiente lección para seguir sumando XP.</p>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-colors border border-transparent hover:border-white/10 group cursor-pointer">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/20 text-blue-400 flex items-center justify-center shrink-0">
|
||||
<BookOpen size={16} />
|
||||
</div>
|
||||
<p className="text-xs font-bold text-gray-300 leading-tight group-hover:text-white transition-colors">Revisa el glosario de términos de esta sección.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-8 rounded-[2rem] bg-amber-500/5 border border-amber-500/10">
|
||||
<p className="text-[10px] font-black text-amber-500 uppercase tracking-widest mb-2">Nota Importante</p>
|
||||
<p className="text-[11px] font-bold text-amber-500/70 leading-relaxed uppercase tracking-tight">
|
||||
Has alcanzado el máximo de intentos. Esta evaluación está bloqueada, pero puedes seguir repasando los materiales del curso.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Download, Eye, ExternalLink } from "lucide-react";
|
||||
import { CMS_API_URL } from "@/lib/api";
|
||||
import { getCmsApiUrl } from "@/lib/api";
|
||||
|
||||
interface DocumentPlayerProps {
|
||||
id: string;
|
||||
@@ -18,7 +18,7 @@ export default function DocumentPlayer({ id, title, url }: DocumentPlayerProps)
|
||||
if (path.startsWith('http')) return path;
|
||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||
return `${CMS_API_URL}${finalPath}`;
|
||||
return `${getCmsApiUrl()}${finalPath}`;
|
||||
};
|
||||
|
||||
const displayUrl = getFullUrl(url);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Play, Lock, AlertCircle } from "lucide-react";
|
||||
import { lmsApi } from "@/lib/api";
|
||||
import { lmsApi, getCmsApiUrl } from "@/lib/api";
|
||||
|
||||
interface MediaPlayerProps {
|
||||
id: string;
|
||||
@@ -46,14 +46,14 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
|
||||
const maxPlays = config?.maxPlays || 0;
|
||||
|
||||
const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
|
||||
|
||||
|
||||
const getFullUrl = (path: string) => {
|
||||
if (path.startsWith('http')) return path;
|
||||
// Map /uploads to /assets for the backend
|
||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||
return `${CMS_API_URL}${finalPath}`;
|
||||
return `${getCmsApiUrl()}${finalPath}`;
|
||||
};
|
||||
|
||||
const isLocalFile = url.startsWith('/uploads') || url.startsWith('http://localhost:3001/assets') || url.includes('/assets/');
|
||||
@@ -129,8 +129,8 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
||||
// Since browser <track> doesn't support custom headers easily,
|
||||
// we might need to handle this via a proxy or temporary signed URLs.
|
||||
// For now, we'll assume the backend allows VTT access if requested with the correct lesson ID.
|
||||
const vttEn = lessonId ? `${CMS_API_URL}/lessons/${lessonId}/vtt?lang=en` : null;
|
||||
const vttEs = lessonId ? `${CMS_API_URL}/lessons/${lessonId}/vtt?lang=es` : null;
|
||||
const vttEn = lessonId ? `${getCmsApiUrl()}/lessons/${lessonId}/vtt?lang=en` : null;
|
||||
const vttEs = lessonId ? `${getCmsApiUrl()}/lessons/${lessonId}/vtt?lang=es` : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" id={id}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { lmsApi, Organization } from '@/lib/api';
|
||||
import { lmsApi, Organization, getImageUrl } from '@/lib/api';
|
||||
|
||||
interface BrandingContextType {
|
||||
branding: Organization | null;
|
||||
@@ -45,14 +45,6 @@ export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
// Import getImageUrl logic locally or assume it needs import
|
||||
// Since I can't easily add import at top with replace_file, I will assume getImageUrl handles the path or do logic here.
|
||||
// Actually I need to import getImageUrl at the top. Instead of complicating, I'll update imports too.
|
||||
const getImageUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
|
||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||
return `${CMS_API_URL}${finalPath}`;
|
||||
};
|
||||
|
||||
const faviconUrl = getImageUrl(data.favicon_url);
|
||||
const link: HTMLLinkElement | null = document.querySelector("link[rel*='icon']");
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002";
|
||||
export const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
|
||||
const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
return `${protocol}//${hostname}:${defaultPort}`;
|
||||
}
|
||||
return envVar || `http://localhost:${defaultPort}`;
|
||||
};
|
||||
|
||||
export const getLmsApiUrl = () => getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LMS_API_URL);
|
||||
export const getCmsApiUrl = () => getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
|
||||
|
||||
export const getImageUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
|
||||
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
|
||||
return `${CMS_API_URL}${finalPath}`;
|
||||
return `${getCmsApiUrl()}${finalPath}`;
|
||||
};
|
||||
|
||||
export interface Organization {
|
||||
@@ -171,7 +180,7 @@ export interface Enrollment {
|
||||
id: string;
|
||||
user_id: string;
|
||||
course_id: string;
|
||||
enroled_at: string;
|
||||
enrolled_at: string;
|
||||
}
|
||||
|
||||
export interface Module {
|
||||
@@ -186,7 +195,7 @@ const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('exp
|
||||
|
||||
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
|
||||
const token = getToken();
|
||||
const baseUrl = isCMS ? CMS_API_URL : API_BASE_URL;
|
||||
const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl();
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
@@ -238,7 +247,7 @@ export const lmsApi = {
|
||||
},
|
||||
|
||||
initSSOLogin(orgId: string): void {
|
||||
window.location.href = `${CMS_API_URL}/auth/sso/login/${orgId}`;
|
||||
window.location.href = `${getCmsApiUrl()}/auth/sso/login/${orgId}`;
|
||||
},
|
||||
|
||||
async enroll(courseId: string, userId: string): Promise<void> {
|
||||
@@ -286,7 +295,7 @@ export const lmsApi = {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const token = getToken();
|
||||
return fetch(`${CMS_API_URL}/assets/upload`, {
|
||||
return fetch(`${getCmsApiUrl()}/assets/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
@@ -331,7 +340,7 @@ export const lmsApi = {
|
||||
formData.append('keywords', JSON.stringify(keywords));
|
||||
|
||||
const token = getToken();
|
||||
return fetch(`${API_BASE_URL}/audio/evaluate-file`, {
|
||||
return fetch(`${getLmsApiUrl()}/audio/evaluate-file`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
@@ -344,5 +353,14 @@ export const lmsApi = {
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
},
|
||||
async chatWithTutor(lessonId: string, message: string): Promise<{ response: string }> {
|
||||
return apiFetch(`/lessons/${lessonId}/chat`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
},
|
||||
async getLessonFeedback(lessonId: string): Promise<{ response: string }> {
|
||||
return apiFetch(`/lessons/${lessonId}/feedback`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cmsApi, Organization, getImageUrl } from '@/lib/api';
|
||||
import { cmsApi, Organization, getImageUrl, API_BASE_URL } from '@/lib/api';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Image from 'next/image';
|
||||
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Fingerprint, Key, Settings2 } from 'lucide-react';
|
||||
@@ -176,18 +176,18 @@ export default function OrganizationsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-6 md:space-y-8 animate-in fade-in duration-500 p-4 md:p-0">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Organizations</h1>
|
||||
<p className="text-gray-400 mt-1">Manage tenants and isolated environments.</p>
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Organizations</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">Manage tenants and isolated environments.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 shadow-glow"
|
||||
className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-lg shadow-blue-500/20 shadow-glow"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Organization
|
||||
<span className="inline">New Organization</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -570,7 +570,7 @@ export default function OrganizationsPage() {
|
||||
</div>
|
||||
<p className="text-[10px] text-blue-300 leading-relaxed">
|
||||
1. Register OpenCCB as an application in your Identity Provider (Okta, Google, Azure AD).<br />
|
||||
2. Set the Redirect URI to: <span className="font-mono bg-blue-500/20 px-1">http://localhost:3001/auth/sso/callback</span><br />
|
||||
2. Set the Redirect URI to: <span className="font-mono bg-blue-500/20 px-1">{API_BASE_URL}/auth/sso/callback</span><br />
|
||||
3. Copy the Issuer URL, Client ID, and Client Secret here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function StudioLoginPage() {
|
||||
<div className="mt-6 pt-6 border-t border-white/10 text-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
Are you a student?{" "}
|
||||
<a href="http://localhost:3003/auth/login" className="text-blue-400 hover:text-blue-300 font-bold">
|
||||
<a href="http://192.168.0.254:3003/auth/login" className="text-blue-400 hover:text-blue-300 font-bold">
|
||||
Go to Student Portal
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -31,7 +31,7 @@ body {
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@apply glass rounded-2xl p-6;
|
||||
@apply glass rounded-2xl p-4 md:p-6;
|
||||
}
|
||||
|
||||
.btn-premium {
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function RootLayout({
|
||||
<I18nProvider>
|
||||
<AuthGuard>
|
||||
<BrandingManager />
|
||||
<header className="h-20 glass sticky top-0 z-50 px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
|
||||
<header className="h-16 md:h-20 glass sticky top-0 z-50 px-4 md:px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
|
||||
<BookOpen size={20} />
|
||||
|
||||
@@ -10,17 +10,17 @@ export default function AuthHeader() {
|
||||
<div className="flex items-center gap-4">
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<Link href="/admin/organizations" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
|
||||
<Building2 size={16} /> Org
|
||||
<Link href="/admin/organizations" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Organizations">
|
||||
<Building2 size={16} /> <span className="hidden md:inline">Org</span>
|
||||
</Link>
|
||||
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
|
||||
<ShieldAlert size={16} /> Audit
|
||||
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Audit Logs">
|
||||
<ShieldAlert size={16} /> <span className="hidden md:inline">Audit</span>
|
||||
</Link>
|
||||
<Link href="/admin/tasks" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
|
||||
<Activity size={16} /> Tasks
|
||||
<Link href="/admin/tasks" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Tasks">
|
||||
<Activity size={16} /> <span className="hidden md:inline">Tasks</span>
|
||||
</Link>
|
||||
<Link href="/settings" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
|
||||
<Settings size={16} /> Settings
|
||||
<Link href="/settings" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2" title="Settings">
|
||||
<Settings size={16} /> <span className="hidden md:inline">Settings</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
|
||||
const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hostname = window.location.hostname;
|
||||
// Detect if we are on a custom domain or IP
|
||||
const protocol = window.location.protocol;
|
||||
return `${protocol}//${hostname}:${defaultPort}`;
|
||||
}
|
||||
return envVar || `http://localhost:${defaultPort}`;
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
|
||||
|
||||
export const getImageUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
|
||||
Reference in New Issue
Block a user