// ============ AcademIA8 · Consola — App root (estado, routing, acciones) ============ // El backend (arq_05) es la fuente de verdad. El estado local es un espejo que se // refresca desde el API tras cada mutación. Nada se persiste en localStorage: se // relee del servidor. `window.A8Api` traduce el modelo del front (lista mixta de // ítems) a los contratos del backend (pasos + checkpoints). const TRAY = '__tray__'; // Estado derivado del backend → status para el pill de la UI. function statusOf(course){ return A8.estadoToStatus(course && course.derivedEstado); } // Guía consolidada de reglas de carga de materiales (mismo contenido que las ayudas ?) const GUIDE_ORDER = ['video','audio','imagen','documento']; function MaterialGuide({ onClose }){ return (
Entendido}>
{GUIDE_ORDER.map(k=>(
{FRAG_LABEL[k]}
{A8_RULES[k]?.help}
))}
Caption del adjunto
Texto opcional que acompaña a un adjunto cuando se envía por WhatsApp. Máximo 1024 caracteres.
); } const A8_RULES = (typeof MEDIA_RULES!=='undefined') ? MEDIA_RULES : {}; function App(){ const [courses, setCourses] = useState([]); const [nav, setNav] = useState({ screen:'list', courseId:null, unitId:null }); const [toast, setToast] = useState(null); const [guide, setGuide] = useState(false); const [sideOpen, setSideOpen] = useState(true); const [busy, setBusy] = useState(false); const [loadError, setLoadError] = useState(null); const [view, setView] = useState('borrador'); // 'borrador' | 'publicado' (toggle del header) // Usuario logueado: se lee al render (después de que boot autenticó), no al // parse del módulo — así el nombre del ID token ya está disponible. const sessionUser = (window.A8Auth && window.A8Auth.user) ? window.A8Auth.user() : { name:'Autor', email:'', initials:'A8' }; // reveal suave una vez montado (evita el pop-in del compilado de Babel) useEffect(()=>{ const r = document.getElementById('root'); if (r){ requestAnimationFrame(()=>requestAnimationFrame(()=>r.classList.add('ready'))); } },[]); const toastMsg = (m)=>{ setToast(m); setTimeout(()=>setToast(null), 2600); }; const findCourse = (id)=> courses.find(c=>c.id===id); function contOf(c, loc){ return loc===TRAY ? (c.draft.tray||{items:[]}) : c.draft.units.find(x=>x.id===loc); } function handleErr(e){ const msg = (e && e.message) ? e.message : 'Ocurrió un error'; toastMsg(msg); // 401 ya dispara logout desde el cliente API. } // ---- carga de datos ---- function upsertCourse(full){ setCourses(cs=>{ const i=cs.findIndex(c=>c.id===full.id); const copy=cs.slice(); if(i>=0) copy[i]=full; else copy.unshift(full); return copy; }); } // Carga (o recarga) un curso. El editor solo necesita el BORRADOR (getCourse): // se renderiza apenas eso resuelve, SIN bloquear en /estado y /diff (que en un // curso publicado suman ~2 round-trips extra y hacían que el editor "no // apareciera" por varios segundos). El estado derivado se siembra de lo ya // conocido (la lista lo trae) y se refina en segundo plano con refreshStatus. async function reload(id){ const full = await A8Api.getCourse(id); const prev = findCourse(id); full.derivedEstado = prev ? prev.derivedEstado : undefined; full.published = prev ? !!prev.published : false; full.nuevasUnitIds = (prev && prev.nuevasUnitIds) || []; full.editadasUnitIds = (prev && prev.editadasUnitIds) || []; upsertCourse(full); // el editor ya puede renderizar el borrador editable refreshStatus(id); // /estado + /diff en segundo plano (no se espera) return full; } // Estado derivado (pill / nota de publicar) y diff (lock de unidades publicadas) // en segundo plano; cuando llegan, actualizan el curso sin bloquear la edición. async function refreshStatus(id){ try { const est = await A8Api.estado(id); const published = est !== 'solo-borrador'; let nuevas = [], editadas = []; if (published){ try { const d = await A8Api.diff(id); nuevas = (d.nuevas||[]).map(x=>x.unidad_id); editadas = (d.editadas||[]).map(x=>x.unidad_id); } catch(e){ /* diff best-effort */ } } setCourses(cs=>cs.map(c=> c.id===id ? { ...c, derivedEstado:est, published, nuevasUnitIds:nuevas, editadasUnitIds:editadas } : c)); } catch(e){ /* el editor ya está; el estado se refina cuando se pueda */ } } async function loadList(){ setLoadError(null); try { const list = await A8Api.listCourses(); setCourses(cs=> list.map(r=>{ const ex = cs.find(c=>c.id===r.curso_id); const patch = { derivedEstado:r.estado_derivado, published:r.estado_derivado!=='solo-borrador' }; if (ex && ex._loaded) return { ...ex, ...patch, meta_draft:{ ...ex.meta_draft, name:r.titulo } }; return { id:r.curso_id, meta_draft:{ name:r.titulo }, ...patch, _light:true }; })); } catch(e){ setLoadError(e.message||String(e)); } } // carga inicial de la lista useEffect(()=>{ loadList(); },[]); // navegación con carga perezosa del borrador completo async function openCourse(id){ setView('borrador'); // al abrir un curso siempre arrancamos en borrador editable setNav({ screen:'course', courseId:id, unitId:null }); const c = findCourse(id); if (!c || !c._loaded){ setBusy(true); try { await reload(id); } catch(e){ handleErr(e); setNav({ screen:'list', courseId:null, unitId:null }); } finally { setBusy(false); } } } // Carga la copia PUBLICADO (snapshot solo-lectura) bajo demanda para el toggle. async function loadPublished(id){ const c = findCourse(id); if (c && c.publishedFront) return; // ya cargada setBusy(true); try { const pub = await A8Api.getCourse(id, 'publicado'); setCourses(cs=>cs.map(x=> x.id===id ? { ...x, publishedFront:pub } : x)); } catch(e){ handleErr(e); setView('borrador'); } finally { setBusy(false); } } // Cambio de vista del header (Borrador / Publicado). function changeView(v){ setView(v); if (v === 'publicado' && current && current.published) loadPublished(current.id); } // helper para envolver una mutación: corre la llamada, recarga y avisa. async function run(id, fn, okMsg){ setBusy(true); try { await fn(); if (id) await reload(id); if (okMsg) toastMsg(okMsg); return true; } catch(e){ if (id){ try{ await reload(id); }catch(_){} } handleErr(e); return false; } finally { setBusy(false); } } const actions = { // navegación goList: ()=>{ setNav({ screen:'list', courseId:null, unitId:null }); loadList(); }, notify: (m)=>toastMsg(m), openCourse, openUnit: (id,uid)=>setNav({ screen:'unit', courseId:id, unitId:uid }), openPublish: (id)=>setNav({ screen:'publish', courseId:id, unitId:null }), // ---- Curso ---- createCourse: async ()=>{ const id = A8.uid('curso'); setBusy(true); try { await A8Api.createCourse(id, { name:'Curso nuevo', description:'', welcome:'', schedule:'Entrega diaria de contenido' }); await reload(id); setNav({ screen:'course', courseId:id, unitId:null }); toastMsg('Curso creado en borrador'); } catch(e){ handleErr(e); } finally { setBusy(false); } }, duplicateCourse: async (origenId, destinoId, titulo)=>{ setBusy(true); try { const r = await A8Api.duplicarCurso(origenId, destinoId, titulo); await reload(r.curso_id); setNav({ screen:'course', courseId:r.curso_id, unitId:null }); toastMsg('Curso duplicado como nuevo borrador'); return true; } catch(e){ handleErr(e); return false; } finally { setBusy(false); } }, deleteCourse: async (id)=>{ const ok = await run(null, ()=>A8Api.deleteCourse(id), 'Curso borrado'); if (ok){ setCourses(cs=>cs.filter(c=>c.id!==id)); setNav({ screen:'list', courseId:null, unitId:null }); loadList(); } }, updateMeta: (id, meta)=> run(id, ()=>A8Api.updateMeta(id, meta), 'Metadata guardada en borrador'), // ---- Unidades ---- addUnit: (id, title)=>{ const c = findCourse(id); const unitId = A8.uid('u'); const posicion = (c.draft.units.length) + 1; return run(id, ()=>A8Api.createUnit(id, unitId, title, posicion), 'Unidad agregada'); }, renameUnit: (id, uid, title)=> run(id, ()=>A8Api.renameUnit(id, uid, title)), deleteUnit: (id, uid)=> run(id, ()=>A8Api.deleteUnit(id, uid), 'Unidad borrada'), reorderUnit: (id, from, to)=>{ const c = findCourse(id); const units = c.draft.units.slice(); if (to<0 || to>=units.length) return; const [m] = units.splice(from,1); units.splice(to,0,m); return run(id, ()=>A8Api.reorderUnits(id, units.map(u=>u.id))); }, // ---- Ítems (texto / pregunta) ---- // loc = id de unidad o '__tray__' (bandeja). addItem: (id, loc, item)=>{ const c = findCourse(id); const cont = contOf(c, loc); const unidad_id = loc===TRAY ? null : loc; const orden = loc===TRAY ? 1 : (cont.items.filter(x=>!x._readonly).length + 1); return run(id, ()=>A8Api.saveItem(id, unidad_id, item, orden), loc===TRAY?'Agregado a la bandeja':'Contenido agregado'); }, addItems: (id, loc, items)=>{ if (!items || !items.length) return; const c = findCourse(id); const cont = contOf(c, loc); const unidad_id = loc===TRAY ? null : loc; let base = loc===TRAY ? 1 : (cont.items.filter(x=>!x._readonly).length + 1); return run(id, async ()=>{ for (let i=0;i1?'s':''} agregado${items.length>1?'s':''}`); }, saveItem: (id, loc, item)=>{ const c = findCourse(id); const cont = contOf(c, loc); const unidad_id = loc===TRAY ? null : loc; const idx = cont ? cont.items.findIndex(x=>x.id===item.id) : -1; const orden = loc===TRAY ? 1 : (idx>=0 ? idx+1 : cont.items.filter(x=>!x._readonly).length+1); return run(id, ()=>A8Api.saveItem(id, unidad_id, item, orden), 'Contenido guardado'); }, renameItem: (id, loc, itemId, patch)=>{ const c = findCourse(id); const cont = contOf(c, loc); const it = cont && cont.items.find(x=>x.id===itemId); if (!it || it._readonly) return; const merged = { ...it, ...patch }; const unidad_id = loc===TRAY ? null : loc; const idx = cont.items.findIndex(x=>x.id===itemId); const orden = loc===TRAY ? 1 : idx+1; return run(id, ()=>A8Api.saveItem(id, unidad_id, merged, orden)); }, deleteItem: (id, loc, itemId)=>{ const c = findCourse(id); const cont = contOf(c, loc); const remaining = (loc===TRAY || !cont) ? [] : cont.items.filter(x=>x.id!==itemId && !x._readonly); return run(id, async ()=>{ await A8Api.deleteItem(id, itemId); if (loc!==TRAY && remaining.length) await A8Api.reorderItems(id, loc, remaining); // renumera orden contiguo }, 'Contenido borrado'); }, // mover un ítem entre contenedores o reordenar dentro de uno. moveItem: (id, fromLoc, fromIx, toLoc, toIx)=>{ const c = findCourse(id); const fromCont = contOf(c, fromLoc), toCont = contOf(c, toLoc); if (!fromCont || !toCont) return; const moving = fromCont.items[fromIx]; if (!moving) return; if (moving._readonly){ toastMsg('Ese contenido es de solo lectura'); return; } return run(id, async ()=>{ if (fromLoc===toLoc){ if (fromLoc===TRAY) return; // la bandeja no tiene orden persistente const items = fromCont.items.slice(); items.splice(fromIx,1); let ti = toIx; if (fromIx!x._readonly)); } else { const toUnit = toLoc===TRAY ? null : toLoc; await A8Api.moveItemToUnit(id, moving.id, toUnit); if (moving.kind==='chk') await A8Api.moveConcepto(id, moving.id, toUnit); // el concepto viaja con la pregunta if (toUnit){ const items = toCont.items.filter(x=>!x._readonly); items.splice(Math.max(0,Math.min(toIx,items.length)),0,moving); await A8Api.reorderItems(id, toUnit, items); } if (fromLoc!==TRAY){ const rest = fromCont.items.filter(x=>x.id!==moving.id && !x._readonly); if (rest.length) await A8Api.reorderItems(id, fromLoc, rest); } } }); }, // publicar publish: (id)=> run(id, ()=>A8Api.publish(id), 'Curso publicado'), }; const current = findCourse(nav.courseId); // Solo los cursos publicados tienen copia PUBLICADO; si no, la vista es siempre borrador. const effectiveView = (current && current.published) ? view : 'borrador'; const showToggle = nav.screen === 'course' && current && current._loaded; // navegación con crumbs const crumbs = []; const crumbLabel = nav.screen==='campanias' ? 'Campañas' : (nav.screen==='costos' ? 'Costos' : 'Cursos WhatsApp'); crumbs.push({ label: crumbLabel, onClick:actions.goList, cur: nav.screen==='list' || nav.screen==='campanias' || nav.screen==='costos' }); if (current){ const nm = current.meta_draft?.name || 'Curso'; crumbs.push({ label:nm, onClick:()=>actions.openCourse(current.id), cur: nav.screen==='course' }); if (nav.screen==='publish') crumbs.push({ label:'Publicar', cur:true }); } return (
{/* SIDEBAR */} {/* MAIN */}
{!sideOpen && ( )}
{crumbs.map((c,i)=>( {i>0 && } {c.cur ? {c.label} : {c.label}} ))}
{showToggle && } {busy && } window.A8Auth.logout()} />
{nav.screen==='list' && } {nav.screen==='course' && current && current._loaded && } {nav.screen==='course' && (!current || !current._loaded) && } {nav.screen==='publish' && current && current._loaded && } {nav.screen==='campanias' && } {nav.screen==='costos' && window.FEATURES.costCalc && }
{toast &&
} {guide && setGuide(false)} />}
); } // Isotipo de marca (fallback limpio en SVG inline; no depende de assets externos). function BrandMark(){ return ( 8 ); } function LoadingScreen(){ return (

Cargando…

Trayendo el borrador del curso desde el servidor.

); } // El render lo dispara boot.jsx tras cargar config.json y autenticar (Cognito). window.__A8_BOOT__ = () => { ReactDOM.createRoot(document.getElementById('root')).render(); };