// ============ 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 && (
)}
{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 (
);
}
// 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 (
);
}
// 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 (
);
}
Object.assign(window, {
PublishView, DiffRow, MEDIA_RULES,
DiffTokens, TwoColumnDetalle, ItemDiffTwoCol, MetaTwoColumnDetalle,
});