feat: Introduce My Learning page with user progress and gamification, standardize LLM models on Llama 3, and optimize branding logo image loading.
This commit is contained in:
+7
-7
@@ -53,13 +53,13 @@ services:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [ gpu ]
|
||||
#deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [ gpu ]
|
||||
|
||||
e2e:
|
||||
build:
|
||||
|
||||
+2
-6
@@ -109,8 +109,6 @@ update_env() {
|
||||
|
||||
# Auto-configure AI variables based on hardware
|
||||
if [ "$HAS_NVIDIA" = true ]; then
|
||||
update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cuda"
|
||||
update_env "WHISPER_DEVICE" "cuda"
|
||||
update_env "LOCAL_LLM_MODEL" "llama3.2:1b"
|
||||
# Uncomment GPU deploy section in docker-compose.yml while preserving indentation
|
||||
sed -i 's/^ #deploy:/ deploy:/' docker-compose.yml
|
||||
@@ -121,8 +119,6 @@ if [ "$HAS_NVIDIA" = true ]; then
|
||||
sed -i 's/^ # count: 1/ count: 1/' docker-compose.yml
|
||||
sed -i 's/^ # capabilities: \[ gpu \]/ capabilities: [ gpu ]/' docker-compose.yml
|
||||
else
|
||||
update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cpu"
|
||||
update_env "WHISPER_DEVICE" "cpu"
|
||||
update_env "LOCAL_LLM_MODEL" "phi3:mini"
|
||||
# Comment GPU deploy section in docker-compose.yml
|
||||
sed -i 's/^ deploy:/ #deploy:/' docker-compose.yml
|
||||
@@ -157,9 +153,9 @@ until docker exec openccb-ollama-1 ollama list &> /dev/null; do sleep 2; done
|
||||
|
||||
echo "📥 Downloading models..."
|
||||
if [ "$HAS_NVIDIA" = true ]; then
|
||||
docker exec openccb-ollama-1 ollama pull llama3:8b
|
||||
docker exec openccb-ollama-1 ollama pull llama3
|
||||
else
|
||||
docker exec openccb-ollama-1 ollama pull phi3:mini
|
||||
docker exec openccb-ollama-1 ollama pull llama3:8b
|
||||
fi
|
||||
|
||||
# 6. Database Initialization (Integrated db-mgmt.sh)
|
||||
|
||||
+4
-4
@@ -92,10 +92,10 @@
|
||||
- [x] Métricas de retención (Implementado)
|
||||
- [x] Mapas de calor de participación (Heatmaps) (Implementado)
|
||||
- [x] **Integración de IA**:
|
||||
- [x] Resúmenes de lecciones generados por IA (Implementado)
|
||||
- [x] Transcripción y traducción de video en tiempo real (IA Local) (Implementado)
|
||||
- [x] Generación automática de quices (Implementado)
|
||||
- [x] **Rutas de Aprendizaje Personalizadas**: Recomendaciones impulsadas por IA
|
||||
- [x] Resúmenes de lecciones generados por IA (Llama 3)
|
||||
- [x] Generación automática de quices (Llama 3)
|
||||
- [ ] Transcripción y traducción de video en tiempo real (Postpuesto - Reemplazado por Llama 3 para otras funciones)
|
||||
- [x] **Rutas de Aprendizaje Personalizadas**: Recomendaciones impulsadas por Llama 3 (Implementado)
|
||||
- [x] **Gamificación Base**: (Implementada a nivel de sistema)
|
||||
- [x] Medallas y logros
|
||||
- [x] Tablas de clasificación (Leaderboards)
|
||||
|
||||
@@ -730,114 +730,18 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
|
||||
.await
|
||||
.map_err(|e| format!("File read failed ({}): {}", file_path, e))?;
|
||||
|
||||
// 4. Configuration
|
||||
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let (url, auth_header, model) = if provider == "local" {
|
||||
let base_url =
|
||||
env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||
(
|
||||
format!("{}/v1/audio/transcriptions", base_url),
|
||||
"".to_string(),
|
||||
"medium".to_string(),
|
||||
)
|
||||
} else {
|
||||
let api_key = env::var("OPENAI_API_KEY").map_err(|_| "Missing OPENAI_API_KEY")?;
|
||||
(
|
||||
"https://api.openai.com/v1/audio/transcriptions".to_string(),
|
||||
format!("Bearer {}", api_key),
|
||||
"whisper-1".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(file_data)
|
||||
.file_name(filename.to_string())
|
||||
.mime_str("application/octet-stream")
|
||||
.map_err(|e| format!("Multipart part creation failed: {}", e))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", part)
|
||||
.text("model", model)
|
||||
.text("response_format", "verbose_json");
|
||||
|
||||
let mut request = client.post(&url).multipart(form);
|
||||
if !auth_header.is_empty() {
|
||||
request = request.header("Authorization", auth_header);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Transcription request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let err_body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Transcription API error: {}", err_body));
|
||||
}
|
||||
|
||||
let whisper_data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Whisper JSON parse failed: {}", e))?;
|
||||
|
||||
// Extract text and segments (cues)
|
||||
let text = whisper_data["text"].as_str().unwrap_or_default();
|
||||
let segments = whisper_data["segments"].as_array();
|
||||
|
||||
let mut cues = Vec::new();
|
||||
if let Some(segments) = segments {
|
||||
for s in segments {
|
||||
cues.push(json!({
|
||||
"start": s["start"],
|
||||
"end": s["end"],
|
||||
"text": s["text"]
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let transcription = json!({
|
||||
"en": text,
|
||||
"es": "",
|
||||
"cues": cues
|
||||
});
|
||||
|
||||
// 5. Update initial transcription
|
||||
// 4. Transcription service is currently disabled in favor of Llama 3
|
||||
tracing::warn!("Transcription service is disabled for lesson {}. Using Whisper removal policy.", lesson_id);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE lessons SET transcription = $1, transcription_status = 'processing' WHERE id = $2",
|
||||
"UPDATE lessons SET transcription_status = 'failed' WHERE id = $1",
|
||||
)
|
||||
.bind(&transcription)
|
||||
.bind(lesson_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("Initial database update failed: {}", e))?;
|
||||
.map_err(|e| format!("Failed to update status to failed: {}", e))?;
|
||||
|
||||
// 6. Translation (Optional/Background within the task)
|
||||
let es_text = match translate_text(text, "es").await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("Translation failed for lesson {}: {}", lesson_id, e);
|
||||
"".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let final_transcription = json!({
|
||||
"en": text,
|
||||
"es": es_text,
|
||||
"cues": cues
|
||||
});
|
||||
|
||||
// 7. Final Update
|
||||
sqlx::query(
|
||||
"UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2",
|
||||
)
|
||||
.bind(final_transcription)
|
||||
.bind(lesson_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("Final database update failed: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
return Err("Transcription service is currently disabled. All AI features now use Llama 3 directly.".to_string());
|
||||
}
|
||||
|
||||
pub async fn get_lesson_vtt(
|
||||
|
||||
Generated
-12
@@ -568,7 +568,6 @@
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -631,7 +630,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
@@ -1105,7 +1103,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1516,7 +1513,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2215,7 +2211,6 @@
|
||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -2378,7 +2373,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -3803,7 +3797,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -5145,7 +5138,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -5346,7 +5338,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -5358,7 +5349,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -6301,7 +6291,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6479,7 +6468,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { lmsApi, Course, Module } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { BookOpen, TrendingUp, Clock, CheckCircle2, Award, Target } from "lucide-react";
|
||||
|
||||
interface CourseWithModules extends Course {
|
||||
modules?: Module[];
|
||||
}
|
||||
|
||||
interface EnrollmentWithProgress {
|
||||
course: CourseWithModules;
|
||||
progress: number;
|
||||
lastAccessed?: string;
|
||||
}
|
||||
|
||||
export default function MyLearningPage() {
|
||||
const [enrollments, setEnrollments] = useState<EnrollmentWithProgress[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [gamification, setGamification] = useState<{ points: number, level: number, badges: any[] } | null>(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const enrollmentData = await lmsApi.getEnrollments(user.id);
|
||||
const gamificationData = await lmsApi.getGamification(user.id);
|
||||
setGamification(gamificationData);
|
||||
|
||||
// Fetch course details for each enrollment
|
||||
const enrichedEnrollments: EnrollmentWithProgress[] = [];
|
||||
for (const enrollment of enrollmentData) {
|
||||
try {
|
||||
const outline = await lmsApi.getCourseOutline(enrollment.course_id);
|
||||
|
||||
// TODO: Implement actual progress tracking
|
||||
// For now, show 0% progress for all courses
|
||||
const progress = 0;
|
||||
|
||||
enrichedEnrollments.push({
|
||||
course: outline,
|
||||
progress,
|
||||
lastAccessed: enrollment.enroled_at
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error loading course ${enrollment.course_id}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
setEnrollments(enrichedEnrollments);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-80 glass-card animate-pulse bg-white/5 border-white/5 rounded-3xl"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-20 text-center">
|
||||
<h1 className="text-4xl font-black mb-4">Inicia Sesión</h1>
|
||||
<p className="text-gray-500 mb-8">Debes iniciar sesión para ver tus cursos.</p>
|
||||
<Link href="/auth/login" className="btn-primary">
|
||||
Ir a Login
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-20">
|
||||
{/* Header */}
|
||||
<div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-indigo-500">
|
||||
<BookOpen size={14} />
|
||||
<span>Mi Aprendizaje</span>
|
||||
</div>
|
||||
<h1 className="text-6xl font-black tracking-tighter leading-none">
|
||||
Mis <span className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-600">Cursos</span>
|
||||
</h1>
|
||||
<p className="text-gray-500 font-medium max-w-xl text-lg">
|
||||
Continúa tu viaje de aprendizaje donde lo dejaste.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gamification Stats */}
|
||||
{gamification && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="glass-card p-4 border-indigo-500/20 bg-indigo-500/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-black text-lg">
|
||||
{gamification.level}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Nivel</p>
|
||||
<p className="text-sm font-bold text-white">{gamification.points} XP</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{gamification.badges.length > 0 && (
|
||||
<div className="glass-card p-4 border-amber-500/20 bg-amber-500/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Award className="w-8 h-8 text-amber-400" />
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Medallas</p>
|
||||
<p className="text-sm font-bold text-white">{gamification.badges.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enrolled Courses */}
|
||||
{enrollments.length === 0 ? (
|
||||
<div className="glass-card p-12 text-center border-dashed">
|
||||
<div className="w-20 h-20 rounded-full bg-indigo-500/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Target className="w-10 h-10 text-indigo-400" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black mb-3">No estás inscrito en ningún curso</h3>
|
||||
<p className="text-gray-500 mb-8 max-w-md mx-auto">
|
||||
Explora nuestro catálogo y comienza tu viaje de aprendizaje hoy.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl font-bold transition-all"
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
Explorar Catálogo
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{enrollments.map(({ course, progress }) => (
|
||||
<Link
|
||||
key={course.id}
|
||||
href={`/courses/${course.id}`}
|
||||
className="glass-card group hover:border-indigo-500/50 transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Progress Ring */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-16 h-16">
|
||||
<svg className="w-16 h-16 transform -rotate-90">
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
className="text-white/10"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
strokeDasharray={`${2 * Math.PI * 28}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 28 * (1 - progress / 100)}`}
|
||||
className="text-indigo-500 transition-all duration-500"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-black text-white">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{progress === 100 && (
|
||||
<CheckCircle2 className="w-8 h-8 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Course Info */}
|
||||
<div>
|
||||
<h3 className="text-xl font-black tracking-tight mb-2 group-hover:text-indigo-400 transition-colors">
|
||||
{course?.title || 'Curso sin título'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 line-clamp-2">
|
||||
{course?.description || "Continúa aprendiendo..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-white/5">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<Clock size={12} />
|
||||
<span>{course.modules?.length || 0} módulos</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<TrendingUp size={12} />
|
||||
<span>{progress < 100 ? 'En progreso' : 'Completado'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
<button className="w-full py-2.5 bg-white/5 hover:bg-indigo-600 border border-white/10 hover:border-indigo-500 rounded-xl font-bold text-sm transition-all group-hover:translate-x-1">
|
||||
{progress === 0 ? 'Comenzar' : progress === 100 ? 'Revisar' : 'Continuar'}
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useTranslation } from "@/context/I18nContext";
|
||||
@@ -124,10 +125,12 @@ export default function ProfilePage() {
|
||||
<div className="relative mb-6">
|
||||
<div className="w-32 h-32 rounded-full bg-blue-600/20 border-4 border-white/5 flex items-center justify-center overflow-hidden shadow-2xl relative">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
<Image
|
||||
src={getImageUrl(avatarUrl)}
|
||||
alt={fullName}
|
||||
className="w-full h-full object-cover"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 128px"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-5xl font-black text-blue-400">
|
||||
@@ -196,7 +199,7 @@ export default function ProfilePage() {
|
||||
<div key={badge.id} className="group relative">
|
||||
<div className="w-full aspect-square rounded-xl bg-white/5 border border-white/5 flex items-center justify-center hover:bg-white/10 transition-colors">
|
||||
{badge.icon_url ? (
|
||||
<img src={badge.icon_url} alt={badge.name} className="w-8 h-8 opacity-60 grayscale group-hover:grayscale-0 group-hover:opacity-100 transition-all" />
|
||||
<Image src={badge.icon_url} alt={badge.name} width={32} height={32} className="opacity-60 grayscale group-hover:grayscale-0 group-hover:opacity-100 transition-all" />
|
||||
) : (
|
||||
<Award size={24} className="text-gray-600 group-hover:text-yellow-500 transition-colors" />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useBranding } from "@/context/BrandingContext";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useTranslation } from "@/context/I18nContext";
|
||||
@@ -17,7 +18,7 @@ export default function AppHeader() {
|
||||
<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">
|
||||
{branding?.logo_url ? (
|
||||
<img src={branding.logo_url} alt={branding.name} className="w-full h-full object-contain" />
|
||||
<Image src={branding.logo_url} alt={branding.name} fill className="object-contain" sizes="40px" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700">L</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { Search, CheckCircle, XCircle, MousePointer2 } from "lucide-react";
|
||||
|
||||
interface Hotspot {
|
||||
@@ -88,10 +89,12 @@ export default function HotspotPlayer({
|
||||
onClick={handleClick}
|
||||
className="relative aspect-video rounded-3xl overflow-hidden border-4 border-white/10 bg-black cursor-crosshair group select-none shadow-2xl"
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
|
||||
{/* Overlay found hotspots */}
|
||||
@@ -144,8 +147,8 @@ export default function HotspotPlayer({
|
||||
<div
|
||||
key={h.id}
|
||||
className={`px-4 py-2 rounded-xl text-xs font-black tracking-widest uppercase transition-all flex items-center gap-2 ${found.includes(h.id)
|
||||
? "bg-green-500 text-white translate-y-[-2px] shadow-lg shadow-green-500/20"
|
||||
: "bg-white/5 text-gray-500 border border-white/5"
|
||||
? "bg-green-500 text-white translate-y-[-2px] shadow-lg shadow-green-500/20"
|
||||
: "bg-white/5 text-gray-500 border border-white/5"
|
||||
}`}
|
||||
>
|
||||
{found.includes(h.id) ? <CheckCircle size={14} /> : <div className="w-1 h-1 rounded-full bg-current" />}
|
||||
|
||||
@@ -297,6 +297,6 @@ export const lmsApi = {
|
||||
});
|
||||
},
|
||||
async getRecommendations(courseId: string): Promise<RecommendationResponse> {
|
||||
return apiFetch(`/courses/${courseId}/recommendations`, {}, true);
|
||||
return apiFetch(`/courses/${courseId}/recommendations`);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user