// ============ AcademIA8 · Consola — Reglas de material + Vista de publicación ============ // El editor de unidad clásico (fragmentos/checkpoints separados) fue reemplazado // por el organizador (organizer.jsx), coherente con el mapeo a pasos del backend. // Este archivo conserva las reglas de material (compartidas con el organizador) y // la vista de publicación, ahora cableada a los endpoints reales del backend // (GET /diff, GET /disclaimer) — sin diff local ni copia publicada en el front. // ---------- Reglas por tipo de material (contenido de las ayudas contextuales) ---------- const MEDIA_RULES = { video: { types:['MP4'], help:(<> Subida directa (hasta 500 MB): se envía como archivo por WhatsApp. Si pesa más de 16 MB se comprime automáticamente al subir (podés ver el progreso en el editor) — el alumno siempre recibe un MP4 dentro del límite de WhatsApp. Video largo por link: alternativa si preferís pegar un link (YouTube, Vimeo) en vez de subir el archivo. El alumno recibe el link, no el archivo. ) }, imagen: { types:['JPEG','PNG'], help:(Hasta 5 MB. Formatos: JPEG, PNG.) }, audio: { types:['AAC','MP3','OGG','Opus'], help:(Hasta 16 MB. Formatos: AAC, MP3, OGG, Opus.) }, documento: { types:['PDF','DOC','DOCX'], help:(Hasta 100 MB. Formatos: PDF, DOC, DOCX, etc.) }, }; /* =================================================================== VISTA DE PUBLICACIÓN (diff borrador vs publicado — backend Camino A) - estado derivado: solo-borrador / publicado-limpio / publicado-con-cambios - diff: unidades `editadas` y `nuevas` (no hay movida ni borrada, Camino A) - disclaimer de alcance: por unidad editada, cuántos alumnos ya la cursaron =================================================================== */ function PublishView({ course, actions }){ const [diff, setDiff] = useState(null); const [disc, setDisc] = useState({}); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [publishing, setPublishing] = useState(false); const [expanded, setExpanded] = useState(()=>new Set()); const estado = course.derivedEstado; const firstPub = estado === 'solo-borrador'; const clean = estado === 'publicado-limpio'; // mapa unidad_id → {title, pos} desde el borrador cargado const unitById = {}; (course.draft?.units || []).forEach((u,i)=>{ unitById[u.id] = { title:u.title, pos:i+1, u }; }); useEffect(()=>{ let alive = true; (async ()=>{ setLoading(true); setErr(null); try { // detalle=true: el backend ya viene con el diff item por item y campo // por campo de cada unidad editada (DIFF-1) — el front solo pinta. const d = await A8Api.diff(course.id, { detalle:true }); let dc = {}; if (d.editadas && d.editadas.length){ try { dc = await A8Api.disclaimer(course.id); } catch(e){ dc = {}; } } if (alive){ setDiff(d); setDisc(dc || {}); } } catch(e){ if (alive) setErr(e.message || String(e)); } finally { if (alive) setLoading(false); } })(); return ()=>{ alive = false; }; }, [course.id]); function toggle(key){ setExpanded(prev=>{ const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } function expandAll(keys){ setExpanded(new Set(keys)); } function collapseAll(){ setExpanded(new Set()); } // Motivos de bloqueo de Camino A que ya vienen calculados en el diff // (`bloquea_publicacion`/`motivo_bloqueo` por item o checkpoint, `violaciones` // por unidad) — front avisa, `camino_a_violations` en el backend sigue siendo // la única puerta autoritativa (ver draft_diff.py). const motivosBloqueo = []; (diff?.editadas || []).forEach(e=>{ (e.violaciones || []).forEach(v=>motivosBloqueo.push(v)); (e.items || []).forEach(it=>{ if (it.bloquea_publicacion && it.motivo_bloqueo) motivosBloqueo.push(it.motivo_bloqueo); (it.campos || []).forEach(c=>{ if (c.campo==='checkpoint' && c.bloquea_publicacion && c.motivo_bloqueo) motivosBloqueo.push(c.motivo_bloqueo); }); }); }); const bloqueaPublicacion = motivosBloqueo.length > 0; async function doPublish(){ setPublishing(true); const ok = await actions.publish(course.id); // recarga + toast; devuelve bool setPublishing(false); if (ok) actions.openCourse(course.id); } const nuevas = diff ? diff.nuevas : []; const editadas = diff ? diff.editadas : []; const metaCambiada = diff ? !!diff.meta_cambiada : false; const totalChanges = nuevas.length + editadas.length + (metaCambiada ? 1 : 0); const affectedTotal = Object.values(disc).reduce((s,n)=>s+n, 0); return (
Vista de publicación

{course.meta_draft?.name}

actions.openCourse(course.id)}>Volver al editor
{/* hero resumen */}
{firstPub ? 'Primera publicación' : clean ? 'Sin cambios' : 'Confirmá los cambios'}

{firstPub ? 'Vas a hacer visible este curso al alumno por primera vez.' : clean ? 'El borrador ya está publicado tal cual. No hay cambios para enviar.' : <>Al publicar, el alumno pasará a ver tu borrador actual.}

{!firstPub && !clean && (
+{nuevas.length}unidades nuevas
{editadas.length}unidades editadas
)}
{/* alcance del Camino A (informativo, no bloquea) */}
Republicar no reordena ni borra unidades ya publicadas: solo se editan las existentes y se agregan al final. El progreso de cada alumno se conserva.
{loading && (

Calculando diferencias…

)} {err && (
No se pudo calcular el diff: {err}
)} {!loading && !err && ( <> {/* diff por unidad */}
{firstPub ? 'Unidades a publicar' : 'Cambios respecto de lo publicado'}
{!firstPub && editadas.length>0 && ( { const allOpen = editadas.every(e=>expanded.has(e.unidad_id)) && (!metaCambiada || expanded.has('meta')); if (allOpen) collapseAll(); else expandAll([...editadas.map(e=>e.unidad_id), ...(metaCambiada?['meta']:[])]); }}> {editadas.every(e=>expanded.has(e.unidad_id)) && (!metaCambiada || expanded.has('meta')) ? 'Colapsar todo' : 'Expandir todo'} )}
{totalChanges===0 ? (

{firstPub ? 'El borrador está vacío' : 'Sin cambios'}

{firstPub ? 'Agregá unidades y contenido antes de publicar.' : 'El borrador coincide con lo publicado.'}

) : (
{metaCambiada && (
toggle('meta')}> Metadata del curso editada {diff.meta_diff && }
Nombre, descripción y/o mensaje de bienvenida
{expanded.has('meta') && diff.meta_diff && (
)}
)} {nuevas.map(e=>{ const u = unitById[e.unidad_id]; return ; })} {editadas.map(e=>{ const u = unitById[e.unidad_id]; const n = disc[e.unidad_id] || 0; const note = n>0 ? `${n} ${n===1?'inscripción ya cursó':'inscripciones ya cursaron'} esta unidad y no ${n===1?'verá':'verán'} esta edición` : null; return toggle(e.unidad_id)} />; })}
)} {/* disclaimer de alcance */} {!firstPub && affectedTotal>0 && ( <>
Hay {affectedTotal} {affectedTotal===1?'alumno':'alumnos'} que no {affectedTotal===1?'verá':'verán'} {editadas.length===1?'este cambio':'estos cambios'} porque ya {affectedTotal===1?'pasó':'pasaron'} por {editadas.length===1?'esa unidad':'esas unidades'}.
)} {/* bloqueo de Camino A (front avisa, backend sigue siendo la puerta) */} {bloqueaPublicacion && ( <>
No se puede publicar: {motivosBloqueo.join('; ')}.
)} {/* barra publicar */}
{firstPub?'Publicar por primera vez':clean?'Nada para publicar':`Publicar ${totalChanges} ${totalChanges===1?'cambio':'cambios'}`}
Esto reemplaza lo que ve el alumno por tu borrador actual. La acción es inmediata.
actions.openCourse(course.id)}>Cancelar {publishing ? 'Publicando…' : firstPub?'Publicar curso':'Publicar cambios'}
)}
); } function DiffRow({ unidadId, title, pos, items, tone, label, note, detalle, violaciones, expanded, onToggle }){ const cnt = A8.countUnitContent({ items: items || [] }); const cls = tone==='add'?'is-add':tone==='edit'?'is-edit':''; const tagCls = tone==='add'?'tag-new':tone==='edit'?'tag-edit':'tag-move'; const clickable = !!detalle; const bloqueada = !!(violaciones && violaciones.length); return (
#{pos} {title} {label && {label}} {bloqueada && bloquea la publicación} {clickable && }
{cnt.frags} cont. {cnt.chks} cuest.
{note &&
{note}
} {bloqueada && (
{violaciones.join('; ')}
)} {expanded && detalle && (
)}
); } /* =================================================================== DIFF-1C · vista de dos columnas (Publicado | Borrador). El backend ya calculó todo el diff — palabra por palabra, campo por campo, y snapshots verbatim de cada copia (`contenido_publicado`/`contenido_borrador`) — este código solo pinta. Alineación por item_id, fila por fila: un reemplazo (paso eliminado + paso nuevo con el mismo `orden`) se lee como tal porque quedan adyacentes en la secuencia que ya ordena el backend — no hay heurística de detección de reemplazo acá. =================================================================== */ function DiffTokens({ tokens }){ if (!tokens) return null; return tokens.map((t,i)=>{ if (t.tipo==='borrado') return {t.valor}; if (t.tipo==='agregado') return {t.valor}; return {t.valor}; }); } function fmtCampoValor(v){ if (v===null || v===undefined) return '(vacío)'; if (typeof v === 'object') return v.s3_key || v.url || JSON.stringify(v); if (typeof v === 'boolean') return v ? 'sí' : 'no'; return String(v); } // De un array de tokens de word-diff (igual/borrado/agregado), el subset que // le toca pintar a cada columna: Publicado conserva igual+borrado (lo que se // va, tachado), Borrador conserva igual+agregado (lo que llega). function ladoTokens(tokens, lado){ if (!tokens) return null; return tokens.filter(t => t.tipo==='igual' || (lado==='publicado' ? t.tipo==='borrado' : t.tipo==='agregado')); } // Texto libre de una columna: si el campo cambió, resaltado palabra por // palabra (solo el lado que le corresponde); si no, el valor plano — pero // SIEMPRE el contenido verbatim del autor, nunca reescrito. function TextoLado({ valor, campo, lado, className }){ if (campo && campo.estado==='editado' && campo.granularidad==='palabra'){ return
; } if (!valor) return null; return
{valor}
; } // Valor discreto de una columna (tipo/bloqueante/media ref): solo se pinta // cuando el campo efectivamente cambió (si no cambió, quien llama muestra el // contenido plano directo, sin pasar por acá). function ValorLado({ campo, lado }){ const v = lado==='publicado' ? campo.antes : campo.despues; const cls = lado==='publicado' ? 'diff-campo-antes' : 'diff-campo-despues'; return {fmtCampoValor(v)}; } // Nota discreta (tipo/bloqueante de un checkpoint o item_recall): solo // aparece si ese campo puntual cambió, aunque el resto del item no. function CampoDiscretoLado({ label, campo, lado }){ if (!campo || campo.estado !== 'editado') return null; return (
{label}
); } // Opciones de opcion_multiple, una columna. Sin diff de fila (campoOpciones // ausente, caso común: item sin cambios) se pintan directo. Con diff de fila // (item editado), cada columna se queda con las filas que le tocan por // posición — Publicado se salta las `nuevo`, Borrador se salta las // `eliminado` — para que la fila alineada siga leyéndose como una lista. function OpcionesLado({ opciones, respuesta, campoOpciones, lado }){ const filas = campoOpciones && campoOpciones.granularidad==='fila' ? campoOpciones.filas : null; if (!filas){ return (
{opciones.map((o,i)=>(
{o}{o===respuesta ? ' ✓' : ''}
))}
); } const propias = lado==='publicado' ? ['igual','editado','eliminado'] : ['igual','editado','nuevo']; return (
{filas.filter(f=>propias.includes(f.estado)).map((f,i)=>{ if (f.estado==='igual') return
{f.valor}{f.valor===respuesta?' ✓':''}
; if (f.estado==='nuevo') return
{f.valor}{f.valor===respuesta?' ✓':''}
; if (f.estado==='eliminado') return
{f.valor}
; return
; })}
); } // Checkpoint embebido en un paso, una columna. `campoCheckpoint` solo existe // si el paso está `editado` — trae el sub-diff campo por campo; si el paso // está `igual`/`nuevo`/`eliminado` se pinta el contenido directo. function CheckpointLado({ checkpoint, campoCheckpoint, lado }){ const camposCk = campoCheckpoint && campoCheckpoint.checkpoint ? campoCheckpoint.checkpoint.campos : null; const campo = (nombre) => camposCk && camposCk.find(c=>c.campo===nombre); const reorientacionEntries = Object.entries(checkpoint.reorientacion || {}).filter(([,v])=>v); return (
Checkpoint
{(checkpoint.opciones || []).length > 0 && ( )} {checkpoint.rubrica && (
rúbrica
)} {reorientacionEntries.map(([k,v])=>(
reorientación · {k.replace('si_','si ')}
))}
); } // Contenido completo de un paso, una columna: texto, material, checkpoint — // siempre verbatim, resaltado palabra por palabra solo donde cambió. function PasoContenidoLado({ contenido, campos, lado }){ if (!contenido) return null; const campo = (nombre) => campos && campos.find(c=>c.campo===nombre); const campoMedia = campo('media'); const campoCaption = campo('media_caption'); return (
{contenido.media && (
material
{campoMedia && campoMedia.estado==='editado' ? : fmtCampoValor(contenido.media)} {contenido.media.caption ? ' — ' : ''}
)} {contenido.checkpoint && ( )}
); } // Contenido completo de un item_recall clásico, una columna. function RecallContenidoLado({ contenido, campos, lado }){ if (!contenido) return null; const campo = (nombre) => campos && campos.find(c=>c.campo===nombre); return (
{(contenido.opciones || []).length > 0 && ( )} {contenido.rubrica && (
rúbrica
)}
); } // Una fila del diff de dos columnas: un paso o item_recall, alineado por // item_id. `igual` → ambos lados en gris, contenido visible sin desplegable // (nada que ocultar). `nuevo`/`eliminado` → hueco de un lado, contenido // entero del otro. `editado` → ambos lados con contenido, resaltado palabra // por palabra solo en lo que cambió. function ItemDiffTwoCol({ item }){ const estadoCls = item.estado==='nuevo' ? 'is-add' : item.estado==='eliminado' ? 'is-del' : item.estado==='editado' ? 'is-edit' : 'is-equal'; const campos = item.campos || null; const checkpointCampo = campos && campos.find(c=>c.campo==='checkpoint'); const bloqueo = (item.bloquea_publicacion && item.motivo_bloqueo) || (checkpointCampo && checkpointCampo.bloquea_publicacion && checkpointCampo.motivo_bloqueo) || null; const respuestaCampo = campos && campos.find(c=>c.campo==='respuesta_referencia'); const opcionesCampo = campos && campos.find(c=>c.campo==='opciones'); const soloRespuestaCambio = item.tipo==='item_recall' && respuestaCampo && respuestaCampo.estado==='editado' && !(opcionesCampo && opcionesCampo.estado==='editado'); const tipoLabel = item.tipo==='paso' ? 'Paso' : 'Pregunta'; const tag = item.estado==='nuevo' ? {texto:'nuevo', cls:'tag-new'} : item.estado==='eliminado' ? {texto:'se elimina', cls:'tag-move'} : item.estado==='editado' ? {texto:'editado', cls:'tag-edit'} : null; const Contenido = item.tipo==='paso' ? PasoContenidoLado : RecallContenidoLado; return (
{tipoLabel} {tag && {tag.texto}} {bloqueo && bloquea la publicación}
{item.contenido_publicado ? : }
{item.contenido_borrador ? : }
{soloRespuestaCambio &&
La respuesta correcta cambió
} {bloqueo &&
{bloqueo}
}
); } // Vista de dos columnas de una unidad editada: la unidad COMPLETA en orden, // items sin cambios incluidos (contexto) — DIFF-1C. function TwoColumnDetalle({ items }){ if (!items || !items.length){ return
Sin contenido en esta unidad.
; } return (
Publicado
Borrador
{items.map(it=>)}
); } // Valor de una columna de metadata del curso: mismo criterio que ValorLado/ // TextoLado pero sin exigir que el campo haya cambiado (la metadata siempre // se muestra completa, dos columnas). function MetaValorLado({ campo, lado }){ const v = lado==='publicado' ? campo.antes : campo.despues; if (campo.granularidad === 'palabra'){ if (campo.estado==='editado'){ return
; } return v ?
{v}
: ; } if (v===null || v===undefined) return ; return {fmtCampoValor(v)}; } function MetaCampoRow({ campo }){ return (
{campo.campo}
); } // Metadata del curso, dos columnas: título/descripción/bienvenida y su media // — siempre completa, no solo lo que cambió (DIFF-1C punto 3). function MetaTwoColumnDetalle({ campos }){ return (
Publicado
Borrador
{campos.map((c,i)=>)}
); } Object.assign(window, { PublishView, DiffRow, MEDIA_RULES, DiffTokens, TwoColumnDetalle, ItemDiffTwoCol, MetaTwoColumnDetalle, });