/* ===== Student cabinet: dashboard, course player, test ===== */
const D4 = window.DATA;
function lessonTitle(lesson){ return typeof lesson==='string' ? lesson : (lesson?.t || 'Урок'); }
function lessonVideo(lesson){ return typeof lesson==='object' && lesson ? lesson.video : ''; }
function lessonFile(lesson){ return typeof lesson==='object' && lesson ? lesson.file : ''; }
function isPdfFile(path){ return /\.pdf(?:[#?].*)?$/i.test(String(path || '')); }
function courseMediaUrl(accessCourseId,path){
const value=String(path||'');
const match=value.match(/^uploads\/courses\/([a-z0-9][a-z0-9-]{0,79})\/(.+)$/i);
if(!match) return value;
const mediaCourseId=match[1];
const accessParam=mediaCourseId===String(accessCourseId||'')
? ''
: `&access_course_id=${encodeURIComponent(accessCourseId)}`;
return `api/course-media.php?course_id=${encodeURIComponent(mediaCourseId)}&path=${encodeURIComponent(match[2])}${accessParam}`;
}
function PdfLessonViewer({ src, title }){
const canvasRef=useRef(null);
const shellRef=useRef(null);
const documentRef=useRef(null);
const renderTaskRef=useRef(null);
const [pageNumber,setPageNumber]=useState(1);
const [pageCount,setPageCount]=useState(0);
const [loading,setLoading]=useState(true);
const [error,setError]=useState('');
useEffect(()=>{
let cancelled=false;
setLoading(true);
setError('');
setPageNumber(1);
setPageCount(0);
documentRef.current=null;
if(!window.pdfjsLib){
setLoading(false);
setError('Модуль просмотра PDF не загрузился. Обновите страницу.');
return ()=>{};
}
window.pdfjsLib.GlobalWorkerOptions.workerSrc='assets/vendor/pdfjs/pdf.worker.min.js';
const task=window.pdfjsLib.getDocument({url:src,withCredentials:true});
task.promise.then(pdf=>{
if(cancelled) return;
documentRef.current=pdf;
setPageCount(pdf.numPages);
setLoading(false);
}).catch(err=>{
if(cancelled) return;
setLoading(false);
setError(err?.message || 'Не удалось открыть PDF.');
});
return ()=>{
cancelled=true;
documentRef.current=null;
task.destroy();
};
},[src]);
useEffect(()=>{
const pdf=documentRef.current;
const canvas=canvasRef.current;
if(!pdf || !canvas || loading || error) return;
let cancelled=false;
pdf.getPage(pageNumber).then(page=>{
if(cancelled) return;
const baseViewport=page.getViewport({scale:1});
const availableWidth=Math.max(280,Math.min(960,(shellRef.current?.clientWidth||900)-32));
const scale=availableWidth/baseViewport.width;
const viewport=page.getViewport({scale});
const pixelRatio=Math.min(window.devicePixelRatio||1,2);
const context=canvas.getContext('2d',{alpha:false});
canvas.width=Math.floor(viewport.width*pixelRatio);
canvas.height=Math.floor(viewport.height*pixelRatio);
canvas.style.width=`${Math.floor(viewport.width)}px`;
canvas.style.height=`${Math.floor(viewport.height)}px`;
renderTaskRef.current=page.render({
canvasContext:context,
viewport,
transform:pixelRatio===1?null:[pixelRatio,0,0,pixelRatio,0,0],
});
return renderTaskRef.current.promise;
}).catch(err=>{
if(!cancelled && err?.name!=='RenderingCancelledException'){
setError(err?.message || 'Не удалось отобразить страницу PDF.');
}
});
return ()=>{
cancelled=true;
renderTaskRef.current?.cancel();
renderTaskRef.current=null;
};
},[pageNumber,pageCount,loading,error]);
return (
e.preventDefault()} style={{border:'1px solid rgba(10,60,80,.12)',borderRadius:18,background:'#eef3f4',overflow:'hidden'}}>
{loading?'Загрузка PDF…':error?'Ошибка просмотра':`${title} · страница ${pageNumber} из ${pageCount}`}
{!loading && !error && pageCount>1 &&
setPageNumber(n=>Math.max(1,n-1))}> Назад
=pageCount} onClick={()=>setPageNumber(n=>Math.min(pageCount,n+1))}>Далее
}
{loading &&
Подготавливаем материал урока…
}
{error &&
{error}
}
);
}
function CabShell({ active, go, onLogout, children, user }){
const [menuOpen,setMenuOpen]=useState(false);
const isLeagueMember = !isStaffRole(user?.role) && !!(user?.league_member || user?.leagueMember);
const leagueItems = [
{ k:'league-rating', t:'Рейтинг', i:'trophy' },
{ k:'league-infrastructure', t:'Инфраструктура', i:'pin' },
{ k:'league-discounts', t:'Скидки', i:'star' },
{ k:'league-pilot-card', t:'Спортивная карточка', i:'award' },
{ k:'league-support', t:'Техподдержка', i:'wrench' },
];
const studentItems = [
{ k:'cabinet', t:'Мои курсы', i:'book' },
{ k:'cab-materials', t:'Материалы', i:'doc' },
{ k:'cab-certs', t:'Сертификаты', i:'award' },
];
const items = [
...(isLeagueMember ? leagueItems : studentItems),
{ k:'cab-settings', t:'Настройки', i:'settings' },
];
function navTo(k){ go(k); setMenuOpen(false); }
return (
setMenuOpen(false)}/>
go('home')}>
Личный кабинет
setMenuOpen(v=>!v)} aria-label="Меню">
{children}
);
}
function Dashboard({ go, enroll, user }){
const data = enroll.map(e=>({ ...e, course: D4.courses.find(c=>c.id===e.id) })).filter(x=>x.course);
const active = data.filter(d=>d.access==='open');
const done = data.filter(d=>d.progress===100).length;
const avg = (()=>{ const s=data.filter(d=>d.testScore!=null); return s.length?Math.round(s.reduce((a,b)=>a+b.testScore,0)/s.length):'—'; })();
return (
Привет, {user?.name || 'студент'} 👋 Продолжайте обучение с того места, где остановились.
go('education')}> Новый курс
{active.length} активных курсов
{avg}{avg!=='—'?'%':''} средний балл
Мои курсы
{data.map(d=>())}
);
}
function EnrollRow({ d, go }){
const c=d.course;
const league = D4.leagueMembership || {};
const displayTitle = c.id==='racing' && league.title ? league.title : c.title;
const isLeague = c.id==='racing';
const locked = d.access!=='open';
return (
{displayTitle}
{d.status==='Завершён' && Завершён }
{d.status==='В процессе' && В процессе }
{locked && {d.status} }
{!locked ? (
isLeague
?
Активно
{d.leaguePlan && {d.leaguePlan} }
Действует до {d.validUntil || '—'}
:
) :
Доступ откроется после подтверждения оплаты администратором.
}
{d.testScore!=null &&
Результат теста: {d.testScore}%
}
{locked
? go('payment',c.id)}>К оплате
: isLeague
? {d.leaguePlan || 'Членство'} {d.validUntil ? `до ${d.validUntil}` : 'срок не указан'}
: go('player',c.id)}>{d.progress===100?'Открыть':'Продолжить'} }
);
}
/* ---------------- COURSE PLAYER ---------------- */
function Player({ id, go, enroll, onProgress }){
const c = D4.courses.find(x=>x.id===id)||D4.courses[0];
const e = enroll.find(x=>x.id===id)||{progress:0};
const flat=[]; c.modules.forEach((m,mi)=>m.lessons.forEach((l,li)=>flat.push({l,mi,li,t:m.t})));
const legacyCompletedCount=Math.max(0,Math.min(flat.length,Math.round(((e.progress||0)/100)*flat.length)));
const initialCompletedLessons=Array.isArray(e.completedLessons)
? [...new Set(e.completedLessons.filter(index=>Number.isInteger(index)&&index>=0&&index
a-b)
: Array.from({length:legacyCompletedCount},(_,index)=>index);
const initialLessonIndex=initialCompletedLessons.lengthindex).find(index=>!initialCompletedLessons.includes(index))??0)
: 0;
const [cur,setCur]=useState(initialLessonIndex);
const [completedLessons,setCompletedLessons]=useState(initialCompletedLessons);
const [progressError,setProgressError]=useState('');
const [finishing,setFinishing]=useState(false);
const lesson=flat[cur];
const rawMaterialSrc = lessonFile(lesson.l);
const rawVideoSrc = lessonVideo(lesson.l);
const materialSrc = courseMediaUrl(c.id,rawMaterialSrc);
const videoSrc = courseMediaUrl(c.id,rawVideoSrc);
const materialIsPdf = isPdfFile(rawMaterialSrc);
const lessonAssetMatch=String(rawMaterialSrc||'').match(/^(uploads\/courses\/[^/]+\/lesson-\d+)\/presentation\.pdf$/i);
const lessonAssetBase=lesson.l?.assetBase||lessonAssetMatch?.[1]||'';
const explicitMaterials=Array.isArray(lesson.l?.materials)?lesson.l.materials:null;
const materialEnabled=key=>explicitMaterials===null||explicitMaterials.includes(key);
const defaultMaterialKey=materialEnabled('pdf')&&materialSrc&&materialIsPdf
? 'pdf'
: materialEnabled('docx')&&lessonAssetBase ? 'docx' : null;
const lessonMaterials=[
materialEnabled('pdf') && materialSrc && materialIsPdf ? {key:'pdf',label:'PDF',icon:'doc',kind:'pdf',src:materialSrc,title:'Презентация PDF'} : null,
materialEnabled('audio') && lessonAssetBase ? {key:'audio',label:'Аудио',icon:'play',kind:'audio',src:courseMediaUrl(c.id,`${lessonAssetBase}/audio.m4a`),title:'Аудиолекция'} : null,
materialEnabled('pptx') && lessonAssetBase ? {key:'pptx',label:'PPTX',icon:'layers',kind:'pdf',src:courseMediaUrl(c.id,`${lessonAssetBase}/presentation-preview.pdf`),title:'Презентация PowerPoint'} : null,
materialEnabled('docx') && lessonAssetBase ? {key:'docx',label:'DOCX',icon:'doc',kind:'pdf',src:courseMediaUrl(c.id,`${lessonAssetBase}/notes-preview.pdf`),title:'Конспект DOCX'} : null,
c.id==='aeronavigation-bpla-part-1' && /\/lesson-03$/i.test(lessonAssetBase)
? {key:'audio-2',label:'Доп. аудио',icon:'play',kind:'audio',src:courseMediaUrl(c.id,`${lessonAssetBase}/audio-2.m4a`),title:'Дополнительная аудиолекция'}
: null,
].filter(Boolean);
const [activeMaterial,setActiveMaterial]=useState(defaultMaterialKey);
const selectedMaterial=lessonMaterials.find(item=>item.key===activeMaterial)||null;
const isLast = cur===flat.length-1;
const currentCompleted=completedLessons.includes(cur);
const allLessonsCompleted=completedLessons.length===flat.length;
const canFinishFromLast=isLast&&(allLessonsCompleted||(!currentCompleted&&completedLessons.length===flat.length-1));
useEffect(()=>{
setActiveMaterial(defaultMaterialKey);
},[cur, defaultMaterialKey]);
function goLesson(next){
const bounded = Math.max(0, Math.min(flat.length-1, next));
setCur(bounded);
}
function setLessonCompletion(index,completed){
setProgressError('');
setCompletedLessons(current=>{
const next=completed?[...new Set([...current,index])]:current.filter(value=>value!==index);
return next.sort((a,b)=>a-b);
});
const request=onProgress&&onProgress(c.id,index,flat.length,completed);
if(request&&typeof request.catch==='function'){
return request.catch(error=>{
setCompletedLessons(current=>completed?current.filter(value=>value!==index):[...new Set([...current,index])].sort((a,b)=>a-b));
setProgressError(error?.message||'Не удалось сохранить прохождение урока. Обновите страницу и попробуйте ещё раз.');
throw error;
});
}
return Promise.resolve();
}
function goNextLesson(){
if(!currentCompleted)setLessonCompletion(cur,true).catch(()=>{});
goLesson(cur+1);
}
async function finishAndOpenTest(){
if(!canFinishFromLast||finishing)return;
setFinishing(true);
try{
if(!currentCompleted)await setLessonCompletion(cur,true);
go('test',c.id);
}catch(error){}
finally{setFinishing(false);}
}
return (
go('cabinet')}> Кабинет
{c.title}
Урок {cur+1} / {flat.length}
{videoSrc
?
setLessonCompletion(cur,true).catch(()=>{})} onContextMenu={e=>e.preventDefault()} style={{position:'absolute',inset:0,width:'100%',height:'100%',objectFit:'cover',borderRadius:'inherit'}}>
:
Используйте доступный конспект урока }
{lesson.t}
{lessonTitle(lesson.l)}
В этом уроке разбираем тему на практике: ключевые понятия, демонстрация на симуляторе и чек-лист для самостоятельной отработки. Учебные материалы и конспект доступны во вкладке ниже.
{lessonMaterials.map(item=>(
setActiveMaterial(current=>current===item.key?null:item.key)}>
{item.label}
))}
{!lessonMaterials.length && Материалы не прикреплены }
{videoSrc && Защищённое видео }
Симулятор-сценарий
setLessonCompletion(cur,!currentCompleted).catch(()=>{})}> {currentCompleted?'Урок пройден — снять отметку':'Отметить урок пройденным'}
{selectedMaterial && (
{selectedMaterial.title}
setActiveMaterial(null)}>
Скрыть
{selectedMaterial.kind==='audio'
?
:
}
)}
goLesson(cur-1)}> Назад
{isLast
? {finishing?'Сохраняем…':currentCompleted?'Перейти к тестированию':'Завершить урок и перейти к тестированию'}
: Завершить и перейти далее }
{isLast&&!canFinishFromLast&&
Для тестирования отметьте пройденными остальные уроки. Осталось: {flat.length-completedLessons.length}.
}
{progressError&&
{progressError}
}
Программа курса
{c.modules.map((m,mi)=>(
{String(mi+1).padStart(2,'0')} · {m.t}
{m.lessons.map((l,li)=>{
const idx=flat.findIndex(f=>f.mi===mi&&f.li===li);
const doneL=completedLessons.includes(idx), on=idx===cur;
return (
goLesson(idx)}>
{doneL?:''}
{lessonTitle(l)}
);
})}
))}
);
}
Object.assign(window, { CabShell, Dashboard, Player });