// ============ 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 (
);
}
// Isotipo de marca (fallback limpio en SVG inline; no depende de assets externos).
function BrandMark(){
return (
);
}
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();
};