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:
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