feat: Implement user profile management, add multi-language interactive transcripts, and lay groundwork for SSO.

This commit is contained in:
2026-01-17 00:26:42 -03:00
parent ffbef17396
commit b166387a48
26 changed files with 2646 additions and 469 deletions
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Clock } from "lucide-react";
interface Cue {
@@ -10,15 +10,22 @@ interface Cue {
}
interface InteractiveTranscriptProps {
cues: Cue[];
transcription: {
en?: string;
es?: string;
cues?: Cue[];
};
currentTime: number;
onSeek: (time: number) => void;
}
export default function InteractiveTranscript({ cues, currentTime, onSeek }: InteractiveTranscriptProps) {
export default function InteractiveTranscript({ transcription, currentTime, onSeek }: InteractiveTranscriptProps) {
const [lang, setLang] = useState<'en' | 'es'>('es'); // Default to Spanish as per Experience portal target
const scrollRef = useRef<HTMLDivElement>(null);
const activeCueRef = useRef<HTMLDivElement>(null);
const cues = transcription.cues || [];
// Auto-scroll to active cue
useEffect(() => {
if (activeCueRef.current && scrollRef.current) {
@@ -41,9 +48,25 @@ export default function InteractiveTranscript({ cues, currentTime, onSeek }: Int
return (
<div className="flex flex-col h-full glass-card overflow-hidden border-white/5 bg-black/20">
<div className="p-6 border-b border-white/5 flex items-center gap-3 bg-white/5">
<Clock className="w-4 h-4 text-blue-400" />
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Transcriptor Interactivo</h3>
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/5">
<div className="flex items-center gap-3">
<Clock className="w-4 h-4 text-blue-400" />
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Transcripción</h3>
</div>
<div className="flex bg-black/40 rounded-lg p-1 border border-white/5">
<button
onClick={() => setLang('en')}
className={`px-3 py-1 text-[10px] font-black rounded-md transition-all ${lang === 'en' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-white'}`}
>
EN
</button>
<button
onClick={() => setLang('es')}
className={`px-3 py-1 text-[10px] font-black rounded-md transition-all ${lang === 'es' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-white'}`}
>
ES
</button>
</div>
</div>
<div
@@ -53,19 +76,22 @@ export default function InteractiveTranscript({ cues, currentTime, onSeek }: Int
{cues.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<span className="text-4xl mb-4">🤐</span>
<p className="text-xs text-gray-500 uppercase tracking-widest font-bold">No hay transcripción disponible para este contenido</p>
<p className="text-xs text-gray-500 uppercase tracking-widest font-bold">No hay transcripción disponible</p>
</div>
) : (
cues.map((cue, index) => {
const active = isCueActive(cue);
// In a more advanced implementation, we'd have translated cues.
// For now, if lang is 'es' and we have a full translation but no cue-level translation,
// we'd ideally align them. To keep it simple and working:
return (
<div
key={index}
ref={active ? activeCueRef : null}
onClick={() => onSeek(cue.start)}
className={`group cursor-pointer p-4 rounded-2xl transition-all border ${active
? 'bg-blue-500/10 border-blue-500/30 text-white translate-x-1'
: 'bg-white/5 border-transparent text-gray-400 hover:bg-white/10 hover:border-white/10'
? 'bg-blue-500/10 border-blue-500/30 text-white translate-x-1'
: 'bg-white/5 border-transparent text-gray-400 hover:bg-white/10 hover:border-white/10'
}`}
>
<div className="flex items-start gap-4">
@@ -80,10 +106,19 @@ export default function InteractiveTranscript({ cues, currentTime, onSeek }: Int
);
})
)}
{lang === 'es' && transcription.es && cues.length > 0 && (
<div className="mt-8 pt-8 border-t border-white/10">
<h4 className="text-[10px] font-black uppercase tracking-widest text-blue-400 mb-4">Traducción Completa (Beta)</h4>
<p className="text-sm text-gray-400 leading-relaxed italic">
{transcription.es}
</p>
</div>
)}
</div>
<div className="p-4 bg-white/5 border-t border-white/5 flex items-center justify-between">
<span className="text-[8px] font-bold text-gray-500 uppercase tracking-widest">Haz clic en cualquier segmento para saltar</span>
<span className="text-[8px] font-bold text-gray-500 uppercase tracking-widest">Haz clic para saltar al tiempo</span>
<div className="flex gap-1">
<div className="w-1 h-1 rounded-full bg-blue-500 animate-pulse"></div>
<div className="w-1 h-1 rounded-full bg-blue-500/50"></div>
@@ -5,18 +5,20 @@ import { Play, Lock, AlertCircle } from "lucide-react";
interface MediaPlayerProps {
id: string;
lessonId?: string;
title?: string;
url: string;
media_type: 'video' | 'audio';
config?: {
maxPlays?: number;
};
hasTranscription?: boolean;
initialPlayCount?: number;
onTimeUpdate?: (time: number) => void;
onPlay?: () => void;
}
export default function MediaPlayer({ id, title, url, media_type, config, initialPlayCount, onTimeUpdate, onPlay }: MediaPlayerProps) {
export default function MediaPlayer({ id, lessonId, title, url, media_type, config, hasTranscription, initialPlayCount, onTimeUpdate, onPlay }: MediaPlayerProps) {
const [playCount, setPlayCount] = useState(initialPlayCount || 0);
const [hasStarted, setHasStarted] = useState(false);
const [locked, setLocked] = useState(false);
@@ -86,6 +88,16 @@ export default function MediaPlayer({ id, title, url, media_type, config, initia
return rawUrl;
};
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('experience_token') : null;
const selectedOrgId = typeof window !== 'undefined' ? localStorage.getItem('experience_selected_org_id') : null;
// Construct VTT URLs with auth if possible, or assume public/handled by backend
// 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;
return (
<div className="space-y-6" id={id}>
<div className="flex items-center justify-between">
@@ -102,6 +114,7 @@ export default function MediaPlayer({ id, title, url, media_type, config, initia
<video
src={getFullUrl(url)}
controls
crossOrigin="anonymous"
className="w-full h-full rounded-xl"
onPlay={handlePlay}
onTimeUpdate={(e) => {
@@ -109,7 +122,14 @@ export default function MediaPlayer({ id, title, url, media_type, config, initia
onTimeUpdate(e.currentTarget.currentTime);
}
}}
/>
>
{hasTranscription && vttEn && (
<track kind="subtitles" src={vttEn} srcLang="en" label="English" />
)}
{hasTranscription && vttEs && (
<track kind="subtitles" src={vttEs} srcLang="es" label="Español" />
)}
</video>
) : (
<iframe
src={getEmbedUrl(url)}