// ============ AcademIA8 · Consola — Organizador de contenido (2 paneles + DnD) ============ // Reemplaza el editor de unidad. Trabaja sobre el BORRADOR del curso via `actions`. const TRAY_LOC = '__tray__'; // En cursos ya publicados no se puede reordenar / borrar / insertar unidades (correría el progreso del alumno). const UNIT_LOCK_MSG = 'No disponible en cursos publicados: correría el progreso de los alumnos.'; // ---- validación de archivos (reglas de WhatsApp / Meta) ---- const MB = 1048576; // video: límite de SUBIDA (500MB, no el de entrega — el compresor achica // cualquier video >16MB a ~15.5MB antes de que llegue a publicarse, C4bis // compresor). El resto son límites de entrega, sin compresor, sin cambios. const LIMITS = { video:500, audio:16, imagen:5, documento:100 }; // MB // Umbral desde el que un video dispara compresión automática al subir — // mismo límite de entrega de siempre (WhatsApp), no el de subida de arriba. const VIDEO_COMPRESS_THRESHOLD_MB = 16; const EXT_TYPE = { mp4:'video', mov:'video', mkv:'video', webm:'video', avi:'video', jpg:'imagen', jpeg:'imagen', png:'imagen', gif:'imagen', webp:'imagen', heic:'imagen', mp3:'audio', aac:'audio', ogg:'audio', opus:'audio', m4a:'audio', wav:'audio', pdf:'documento', doc:'documento', docx:'documento', ppt:'documento', pptx:'documento', xls:'documento', xlsx:'documento', txt:'documento', }; const ALLOWED = { video:['mp4'], imagen:['jpg','jpeg','png'], audio:['aac','mp3','ogg','opus'], documento:['pdf','doc','docx','ppt','pptx','xls','xlsx'] }; function humanSize(bytes){ return bytes>=MB ? (bytes/MB).toFixed(1)+' MB' : Math.max(1,Math.round(bytes/1024))+' KB'; } function classify(file){ const ext=(file.name.split('.').pop()||'').toLowerCase(); const type=EXT_TYPE[ext]; const sizeMB=file.size/MB; if(!type) return { type:null, ok:false, reason:'Formato no soportado' }; if(!ALLOWED[type].includes(ext)) return { type, ok:false, reason:`Formato no permitido · usá ${ALLOWED[type].slice(0,3).join('/').toUpperCase()}` }; const lim=LIMITS[type]; if(sizeMB>lim) return { type, ok:false, reason: type==='video'?'Supera 500 MB · exportá en 1080p e intentá de nuevo':`Supera ${lim} MB` }; return { type, ok:true, reason:'Listo para agregar' }; } // Etiqueta de display de un ítem: los ítems de texto NO tienen título (el modelo // no lo tiene), así que se deriva del propio texto. Las preguntas usan su enunciado. function itemLabel(item){ if (item.kind==='chk') return item.question || '(pregunta)'; if (item.type && item.type!=='texto') return item.title || FRAG_LABEL[item.type] || '(adjunto)'; const t = (item.text||'').trim(); return t ? (t.length>90 ? t.slice(0,90)+'…' : t) : '(texto vacío)'; } // título editable inline function OgEditable({ value, onChange, className, inpClass }){ const [ed,setEd]=useState(false); const [v,setV]=useState(value); useEffect(()=>setV(value),[value]); const commit=()=>{ setEd(false); const t=v.trim(); if(t&&t!==value) onChange(t); else setV(value); }; if(ed) return setV(e.target.value)} onBlur={commit} onKeyDown={e=>{ if(e.key==='Enter') commit(); if(e.key==='Escape'){ setEd(false); setV(value); } }} onClick={e=>e.stopPropagation()} draggable={false} onDragStart={e=>e.preventDefault()} />; return { e.stopPropagation(); setEd(true); }} title="Clic para renombrar">{value}; } function OgItem({ item, loc, index, over, dnd, onRename, onEdit, onDelete, unitReadonly, breakdown }){ const isFrag = item.kind==='frag'; const cls = isFrag ? `ft-${item.type||'texto'}` : 'ft-chk'; const insert = over && over.loc===loc && over.index===index; const label = itemLabel(item); const ro = !!item._readonly; return (
{ if(ro) return; e.stopPropagation(); dnd.startItem(loc,item.id); }} onDragEnd={dnd.end} onDragOver={(e)=>{ e.preventDefault(); e.stopPropagation(); dnd.onOver(loc,index); }} onDrop={(e)=>{ e.preventDefault(); e.stopPropagation(); dnd.onDrop(loc,index); }}>
{(!isFrag && !ro) ? onRename({question:v})} /> : {label}{ro && !unitReadonly && solo lectura}} {breakdown && {breakdown}}
{!ro && } {!ro && }
); } // Agrupación visual por burst de WhatsApp (vista, no cambia entrega): // `groupBursts`/`itemMessageCount`/`FRENO_LABEL` vienen de console/burst.js // (lógica pura sin JSX, testeada en tests/front/burst_grouping.test.mjs — // espeja compute_segment en src/orchestrator/guion.py:130-158). function OgItemList({ items, loc, empty, over, dnd, onRename, onEdit, onDelete, unitReadonly }){ const zoneOver = over && over.loc===loc; // La agrupación solo tiene sentido sobre el guion real de una unidad: la // bandeja no tiene orden de entrega (ítems sin ubicar), y las unidades // importadas no traen esperaRespuesta/textoComoCaption (ver Fase A). const canGroup = loc!==TRAY_LOC && !unitReadonly; const bursts = canGroup ? groupBursts(items) : null; const renderItem = (it, i) => ( onRename(loc,it.id,patch)} onEdit={()=>onEdit(loc,it)} onDelete={()=>onDelete(loc,it.id)} /> ); let body; if (bursts){ let idx = 0; body = bursts.map((g,gi)=>{ const rendered = g.items.map((it)=>renderItem(it, idx++)); const n = g.items.reduce((s,it)=>s+itemMessageCount(it), 0); return (
{n} mensaje{n===1?'':'s'} juntos
{rendered}
{FRENO_LABEL[g.freno]}
); }); } else { body = items.map((it,i)=>renderItem(it,i)); } return (
{ e.preventDefault(); dnd.onOver(loc, items.length); }} onDrop={(e)=>{ e.preventDefault(); dnd.onDrop(loc, items.length); }}> {items.length===0 &&
{empty}
} {unitReadonly && loc!==TRAY_LOC && items.length>0 && (
Agrupación de mensajes: no aplica (contenido importado)
)} {body}
); } // ================= Panel Agregar / Editar contenido ================= // - NUEVO: importador MÚLTIPLE de archivos con validación (sí/no) por cada uno. // - Texto y Cuestionario se agregan por separado (alta manual). // - EDITAR: formulario simple del item existente. function ContentForm({ data, cursoId, unidadId, onClose, onSaveItem, onAddItems }){ const editing = !!data; const mediaOn = window.FEATURES.mediaNonText; // C4: media no-texto (video/audio/imagen/doc) const editType = editing ? (data.kind==='chk'?'cuestionario':data.type) : null; // modo: 'choose' (selector texto/pregunta) | 'bulk' (importar archivos, solo con C4) // | 'texto' | 'cuestionario' | 'media' (editar archivo, solo con C4) const initMode = editing ? (data.kind==='chk' ? 'cuestionario' : (data.type==='texto' || !mediaOn ? 'texto' : 'media')) : (mediaOn ? 'bulk' : 'choose'); const [mode,setMode] = useState(initMode); const backMode = mediaOn ? 'bulk' : 'choose'; // a dónde vuelve el back-link // --- estados alta manual (texto / cuestionario / editar media) --- const [title,setTitle] = useState(data?.title || ''); const [text,setText] = useState(data?.text || ''); // Uploader v2: subida real (presigned PUT). `s3Key` sigue siendo la fuente de // verdad que se guarda; se completa sola al terminar de subir, o a mano si el // autor usa el fallback ("pegar la key a mano"). const [s3Key,setS3Key] = useState(data?.s3Key || ''); const [uploadPct,setUploadPct] = useState(data?.s3Key ? 100 : 0); const [uploadErr,setUploadErr] = useState(null); const [manualMode,setManualMode] = useState(false); const [compressionStatus,setCompressionStatus] = useState(null); const [compressionInfo,setCompressionInfo] = useState(null); const editFileRef = useRef(null); const [caption,setCaption] = useState(data?.caption || ''); // C4bis (arq_06bis): flags de paso, default false = comportamiento de hoy. const [textoComoCaption,setTextoComoCaption] = useState(data?.textoComoCaption || false); const [esperaRespuesta,setEsperaRespuesta] = useState(data?.esperaRespuesta || false); const [q,setQ] = useState(data?.question || ''); const [tipoPregunta,setTipoPregunta] = useState(data?.tipoPregunta || 'opcion_multiple'); const [opts,setOpts] = useState(data?.options ? [...data.options] : ['Opción 1','Opción 2']); const [correct,setCorrect] = useState(data?.correct ?? 0); const [rubrica,setRubrica] = useState(data?.rubrica || ''); // f) checkpoint: bloqueante (el alumno no avanza hasta responder bien) + reorientación // por rúbrica en sus tres ramas (si_correcto/si_parcial/si_incorrecto — el motor // las lee así, ver orchestrator/agent/tools.py:_reorientacion_hint). const [blocking,setBlocking] = useState(data?.blocking ?? false); const [reorientCorrecto,setReorientCorrecto] = useState(data?.reorientCorrecto || ''); const [reorientParcial,setReorientParcial] = useState(data?.reorientParcial || ''); const [reorientIncorrecto,setReorientIncorrecto] = useState(data?.reorientIncorrecto || ''); // --- estados importador --- const [files,setFiles] = useState([]); const [over,setOver] = useState(false); const inputRef = useRef(null); // Uploader v2: cada archivo válido arranca a subir solo (presigned PUT) apenas // se elige/arrastra. `s3Key` se completa cuando termina; `manualKey` marca los // que el autor prefirió pegar a mano (fallback) en vez de subir. async function startUpload(fileObj, rawFile){ setFiles(prev=>prev.map(x=>x.id===fileObj.id?{...x,uploading:true,error:null}:x)); try { const { upload_url, s3_key, content_type } = await A8Api.getMediaUploadUrl(cursoId, { filename: rawFile.name, sizeBytes: rawFile.size, unidadId, }); await A8Api.uploadFileToS3(upload_url, rawFile, content_type, (pct)=> setFiles(prev=>prev.map(x=>x.id===fileObj.id?{...x,progress:pct}:x))); setFiles(prev=>prev.map(x=>x.id===fileObj.id ? {...x,uploading:false,uploaded:true,progress:100,s3Key:s3_key,error:null} : x)); // Video >16MB dispara compresión automática (S3 event, C4bis compresor). // El sidecar puede tardar un toque en aparecer recién subido el archivo. if(fileObj.type==='video' && rawFile.size > VIDEO_COMPRESS_THRESHOLD_MB*MB){ setFiles(prev=>prev.map(x=>x.id===fileObj.id?{...x,compressionStatus:'procesando'}:x)); A8Api.pollMediaStatus(cursoId, s3_key, (st)=> setFiles(prev=>prev.map(x=>x.id===fileObj.id?{...x,compressionStatus:st.status,compressionInfo:st}:x))); } } catch(e){ setFiles(prev=>prev.map(x=>x.id===fileObj.id ? {...x,uploading:false,uploaded:false,error:(e&&e.message)||'Error al subir'} : x)); } } function addFiles(fileList){ const raws = Array.from(fileList||[]); const arr = raws.map(f=>{ const c=classify(f); return { id:A8.uid('f'), name:f.name, human:humanSize(f.size), s3Key:'', progress:0, uploading:false, uploaded:false, error:null, manualKey:false, _raw:f, ...c }; }); if(arr.length) setFiles(prev=>[...prev, ...arr]); arr.forEach((fo,i)=>{ if(fo.ok) startUpload(fo, raws[i]); }); } const removeFile=(id)=> setFiles(files.filter(f=>f.id!==id)); const setFileKey=(id,v)=> setFiles(files.map(f=>f.id===id?{...f,s3Key:v}:f)); const toggleManual=(id)=> setFiles(files.map(f=>f.id===id?{...f,manualKey:!f.manualKey}:f)); const retryUpload=(id)=>{ const fo = files.find(f=>f.id===id); if (fo && fo._raw) startUpload(fo, fo._raw); }; const validFiles = files.filter(f=>f.ok && f.s3Key.trim() && (f.uploaded || f.manualKey)); function saveBulk(){ const items = validFiles.map(f=>({ id:A8.uid('it'), kind:'frag', type:f.type, title:f.name.replace(/\.[^.]+$/,''), s3Key:f.s3Key.trim() })); onAddItems(items); } // --- alta/edición manual --- const setOpt=(i,v)=>{ const n=[...opts]; n[i]=v; setOpts(n); }; const addOpt=()=> opts.length<6 && setOpts([...opts,'']); const delOpt=(i)=>{ if(opts.length<=2) return; const n=opts.filter((_,j)=>j!==i); setOpts(n); if(correct>=n.length||correct===i) setCorrect(0); }; function saveText(){ onSaveItem({ id:data?.id||A8.uid('it'), kind:'frag', type:'texto', title:title.trim(), text:text.trim(), esperaRespuesta }); } function saveMedia(){ const fr={ id:data?.id||A8.uid('it'), kind:'frag', type:editType, title:title.trim(), s3Key:s3Key.trim(), text:text.trim(), textoComoCaption, esperaRespuesta }; if(caption.trim()) fr.caption=caption.trim(); onSaveItem(fr); } function saveChk(){ const reorientFields = { reorientCorrecto: reorientCorrecto.trim(), reorientParcial: reorientParcial.trim(), reorientIncorrecto: reorientIncorrecto.trim(), }; if (tipoPregunta==='abierta'){ onSaveItem({ id:data?.id||A8.uid('it'), kind:'chk', tipoPregunta:'abierta', question:q.trim(), rubrica:rubrica.trim(), blocking, ...reorientFields }); return; } const clean=opts.map(o=>o.trim()).filter(Boolean); onSaveItem({ id:data?.id||A8.uid('it'), kind:'chk', tipoPregunta:'opcion_multiple', question:q.trim(), options:clean, correct:Math.min(correct,clean.length-1), blocking, ...reorientFields }); } const modalTitle = editing ? 'Editar contenido' : (mode==='bulk'?'Agregar contenido':(mode==='cuestionario'?'Agregar cuestionario':'Agregar texto')); // ---------------- MODO SELECTOR (sin media, C4 apagado) ---------------- if(mode==='choose'){ return (
Cancelar}>
La carga de video, audio, imagen y documentos estará disponible próximamente.
); } // ---------------- MODO IMPORTAR (múltiple) ---------------- if(mode==='bulk'){ return (
Cancelar{validFiles.length?`Agregar ${validFiles.length} archivo${validFiles.length>1?'s':''}`:'Agregar archivos'}}> { addFiles(e.target.files); e.target.value=''; }} />
{e.preventDefault();setOver(true);}} onDragLeave={()=>setOver(false)} onDrop={e=>{e.preventDefault();setOver(false);addFiles(e.dataTransfer.files);}} onClick={()=>inputRef.current&&inputRef.current.click()}>
Arrastrá varios archivos o hacé clic para elegirlos
Video hasta 500 MB (se comprime solo si pesa más de 16 MB) · Audio hasta 16 MB · Imagen 5 MB · Documento 100 MB.
{files.length>0 && (
{files.map(f=>(
{f.name}
{f.reason}
{f.ok && f.uploading && (
Subiendo… {f.progress}%
)} {f.ok && f.uploaded && (
Subido
)} {f.compressionStatus && } {f.ok && f.error && (
{f.error}
)} {f.ok && !f.uploading && !f.uploaded && ( )} {f.ok && f.manualKey && ( setFileKey(f.id, e.target.value)} style={{marginTop:'4px',width:'100%'}} /> )}
{f.human}
))}
)}
¿No es un archivo?
); } // ---------------- MODO TEXTO ---------------- if(mode==='texto'){ return (
CancelarGuardar en borrador}> {!editing && }