feat: Dynamic student lesson discovery, lesson selector carousel, and live Socratic checkpoint streaming
This commit is contained in:
@@ -297,6 +297,35 @@ class VideoController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all published lessons for student portal
|
||||
* GET /api/student/lessons
|
||||
*/
|
||||
public function getStudentLessons(Request $request, Response $response): void
|
||||
{
|
||||
VideoService::ensureSchema();
|
||||
CurriculumService::ensureSchema();
|
||||
|
||||
$courseId = (int)($request->getQuery('course_id') ?: 0);
|
||||
$where = $courseId > 0 ? "WHERE l.course_id = {$courseId}" : "";
|
||||
|
||||
$lessons = Database::select(
|
||||
"SELECT l.id, l.course_id, l.title, l.sequence_order, l.storage_type, l.video_uuid, l.hls_url, l.thumbnail_url,
|
||||
l.duration_seconds, l.is_free_preview, l.created_at,
|
||||
c.title as course_title,
|
||||
(SELECT COUNT(*) FROM exams WHERE lesson_id = l.id AND scope = 'in_video_checkpoint') as checkpoints_count
|
||||
FROM lessons l
|
||||
JOIN courses c ON l.course_id = c.id
|
||||
{$where}
|
||||
ORDER BY l.id DESC"
|
||||
);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => $lessons
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Lesson Playback Data with Chapters Roadmap and Socratic Checkpoints
|
||||
* GET /api/lessons/{id}/playback
|
||||
@@ -307,16 +336,23 @@ class VideoController
|
||||
CurriculumService::ensureSchema();
|
||||
|
||||
$lessonId = (int)$request->getParam('id');
|
||||
if (!$lessonId) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'معرف الدرس مطلوب']);
|
||||
$lesson = null;
|
||||
|
||||
if ($lessonId > 0) {
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
||||
}
|
||||
|
||||
if (!$lesson) {
|
||||
// Fallback to latest available lesson
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons ORDER BY id DESC LIMIT 1");
|
||||
}
|
||||
|
||||
if (!$lesson) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'لا توجد دروس متاحة حالياً']);
|
||||
return;
|
||||
}
|
||||
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
||||
if (!$lesson) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الدرس غير موجود']);
|
||||
return;
|
||||
}
|
||||
$lessonId = (int)$lesson['id'];
|
||||
|
||||
// If lesson has no checkpoints yet, generate them autonomously
|
||||
$existingCount = Database::selectOne("SELECT COUNT(*) as cnt FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
|
||||
|
||||
@@ -600,13 +600,24 @@ class StudentPortal
|
||||
|
||||
<!-- TAB 1: Socratic Interactive Lesson Player -->
|
||||
<div id="tab_lessons_content" class="studio-card">
|
||||
<!-- Lesson Selector Carousel -->
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<span style="font-size: 13px; font-weight: 800; color: var(--accent-cyan);">📚 فهرس الحصص والدروس المتاحة:</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted);" id="lessons_count_label">جارٍ التحميل...</span>
|
||||
</div>
|
||||
<div id="student_lessons_carousel" style="display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px;">
|
||||
<!-- Dynamically loaded via loadStudentLessonsList() -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; flex-wrap: wrap; gap: 10px;">
|
||||
<div>
|
||||
<h3 style="font-size: 18px; font-weight: 800;">الدرس 4: قواعد الاشتقاق الأساسية — رياضيات علمي</h3>
|
||||
<span style="font-size: 12px; color: var(--text-muted);">الأستاذ حمزة الغويري • Bunny Stream DRM Protection</span>
|
||||
<h3 style="font-size: 18px; font-weight: 800;" id="current_lesson_title">الدرس: قواعد الاشتقاق والتحليل الجنائي</h3>
|
||||
<span style="font-size: 12px; color: var(--text-muted);" id="current_lesson_subtitle">الأستاذ حمزة الغويري • Cloudflare R2 + HLS Adaptive Stream</span>
|
||||
</div>
|
||||
<span style="font-size: 11px; font-weight: 800; color: var(--accent-cyan); background: rgba(0,245,212,0.1); border: 1px solid rgba(0,245,212,0.3); padding: 5px 14px; border-radius: 980px;">
|
||||
Checkpoint Active (00:15)
|
||||
<span style="font-size: 11px; font-weight: 800; color: var(--accent-cyan); background: rgba(0,245,212,0.1); border: 1px solid rgba(0,245,212,0.3); padding: 5px 14px; border-radius: 980px;" id="checkpoint_status_badge">
|
||||
الذكاء الاصطناعي السقراطي نشط 🧠
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1048,6 +1059,7 @@ class StudentPortal
|
||||
let activeLessonCheckpoints = [];
|
||||
let triggeredCheckpoints = new Set();
|
||||
let hlsInstance = null;
|
||||
let activeLessonId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const token = localStorage.getItem('saqel_student_jwt');
|
||||
@@ -1061,7 +1073,7 @@ class StudentPortal
|
||||
}
|
||||
initWebSocket(token);
|
||||
await checkStudentSession(token);
|
||||
await loadStudentLessonPlayback(1);
|
||||
await loadStudentLessonsList();
|
||||
}
|
||||
|
||||
// Video player time listener for Socratic checkpoint
|
||||
@@ -1084,6 +1096,55 @@ class StudentPortal
|
||||
}
|
||||
});
|
||||
|
||||
async function loadStudentLessonsList() {
|
||||
const token = localStorage.getItem('saqel_student_jwt');
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch('/api/student/lessons', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.data && data.data.length > 0) {
|
||||
const lessons = data.data;
|
||||
const countLabel = document.getElementById('lessons_count_label');
|
||||
if (countLabel) countLabel.textContent = `${lessons.length} حصص متاحة`;
|
||||
|
||||
const carousel = document.getElementById('student_lessons_carousel');
|
||||
if (carousel) {
|
||||
carousel.innerHTML = lessons.map((les, idx) => `
|
||||
<button type="button" onclick="selectStudentLesson(${les.id})" id="btn_lesson_${les.id}" style="padding: 8px 16px; font-size: 12px; border-radius: 12px; cursor: pointer; white-space: nowrap; transition: all 0.2s ease; display: flex; align-items: center; gap: 8px; font-family: inherit; font-weight: 700; ${idx === 0 ? 'background: linear-gradient(135deg, #0284C7, #0369A1); color: #FFF; border: 1px solid #38BDF8;' : 'background: rgba(255,255,255,0.06); color: var(--text-secondary); border: 1px solid var(--border);'}">
|
||||
<span>🎬 ${escapeHtml(les.title)}</span>
|
||||
<span style="font-size: 10px; background: rgba(0,0,0,0.35); padding: 2px 8px; border-radius: 4px; color: var(--accent-cyan);">🧠 ${les.checkpoints_count || 0} فحص</span>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
if (!activeLessonId && lessons[0]) {
|
||||
selectStudentLesson(lessons[0].id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load lessons list notice:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function selectStudentLesson(lessonId) {
|
||||
activeLessonId = lessonId;
|
||||
const btns = document.querySelectorAll('#student_lessons_carousel button');
|
||||
btns.forEach(b => {
|
||||
b.style.background = 'rgba(255,255,255,0.06)';
|
||||
b.style.color = 'var(--text-secondary)';
|
||||
b.style.borderColor = 'var(--border)';
|
||||
});
|
||||
const activeBtn = document.getElementById(`btn_lesson_${lessonId}`);
|
||||
if (activeBtn) {
|
||||
activeBtn.style.background = 'linear-gradient(135deg, #0284C7, #0369A1)';
|
||||
activeBtn.style.color = '#FFF';
|
||||
activeBtn.style.borderColor = '#38BDF8';
|
||||
}
|
||||
loadStudentLessonPlayback(lessonId);
|
||||
}
|
||||
|
||||
function formatTime(secs) {
|
||||
const m = Math.floor(secs / 60).toString().padStart(2, '0');
|
||||
const s = (secs % 60).toString().padStart(2, '0');
|
||||
@@ -1114,11 +1175,19 @@ class StudentPortal
|
||||
}
|
||||
|
||||
function triggerCheckpointDemo() {
|
||||
const video = document.getElementById('lesson_video_player');
|
||||
if (video) {
|
||||
video.currentTime = 14;
|
||||
triggeredCheckpoints.clear();
|
||||
video.play();
|
||||
if (activeLessonCheckpoints.length > 0) {
|
||||
renderSocraticModal(activeLessonCheckpoints[0]);
|
||||
} else {
|
||||
renderSocraticModal({
|
||||
question_text: "إذا كان f(x) = sin(3x)، فما هي مشتقة الاقتران f'(x)؟",
|
||||
options: [
|
||||
{ text: "3 cos(3x) (مشتقة الزاوية ضرب مشتقة الاقتران)", is_correct: true },
|
||||
{ text: "cos(3x)", is_correct: false },
|
||||
{ text: "-3 cos(3x)", is_correct: false },
|
||||
{ text: "-cos(3x)", is_correct: false }
|
||||
],
|
||||
rewind_on_fail_seconds: 45
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,19 +1237,30 @@ class StudentPortal
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStudentLessonPlayback(lessonId = 1) {
|
||||
async function loadStudentLessonPlayback(lessonId = 0) {
|
||||
const token = localStorage.getItem('saqel_student_jwt');
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch(`/api/lessons/${lessonId}/playback`, {
|
||||
const targetUrl = lessonId > 0 ? `/api/lessons/${lessonId}/playback` : '/api/lessons/0/playback';
|
||||
const res = await fetch(targetUrl, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'success') {
|
||||
const les = data.data.lesson;
|
||||
const pb = data.data.playback;
|
||||
const checkpoints = data.data.checkpoints || [];
|
||||
const chapters = data.data.chapters || [];
|
||||
activeLessonCheckpoints = checkpoints;
|
||||
triggeredCheckpoints.clear();
|
||||
|
||||
// Update Titles
|
||||
if (les) {
|
||||
const titleEl = document.getElementById('current_lesson_title');
|
||||
const subtitleEl = document.getElementById('current_lesson_subtitle');
|
||||
if (titleEl) titleEl.textContent = `الدرس: ${les.title}`;
|
||||
if (subtitleEl) subtitleEl.textContent = `الأستاذ حمزة الغويري • Cloudflare R2 + HLS Adaptive Stream`;
|
||||
}
|
||||
|
||||
// Update Checkpoint Status badge
|
||||
const badge = document.getElementById('checkpoint_status_badge');
|
||||
@@ -1190,7 +1270,8 @@ class StudentPortal
|
||||
|
||||
// Render AI Timeline Chapters
|
||||
const chaptersList = document.getElementById('ai_timeline_chapters_list');
|
||||
if (chaptersList && chapters.length > 0) {
|
||||
if (chaptersList) {
|
||||
if (chapters.length > 0) {
|
||||
chaptersList.innerHTML = chapters.map((ch, idx) => `
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 12px; padding: 12px; cursor: pointer; transition: all 0.2s ease;" onclick="seekToSeconds(${ch.start_seconds})" onmouseover="this.style.borderColor='var(--accent-cyan)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 11.5px; color: var(--accent-gold); font-weight: 700; margin-bottom: 4px;">
|
||||
@@ -1200,6 +1281,17 @@ class StudentPortal
|
||||
<div style="font-size: 12px; color: var(--text-secondary); line-height: 1.4;">${escapeHtml(ch.summary || '')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
chaptersList.innerHTML = `
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 12px; padding: 12px; cursor: pointer;" onclick="seekToSeconds(0)">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 11.5px; color: var(--accent-gold); font-weight: 700; margin-bottom: 4px;">
|
||||
<span>المحطة 1: الشرح الكامل والتطبيقات</span>
|
||||
<span style="font-family: monospace; color: var(--accent-cyan);">00:00</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--text-secondary);">استعراض المفاهيم وتطبيقات المنهاج الوزاري.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const video = document.getElementById('lesson_video_player');
|
||||
|
||||
@@ -73,15 +73,13 @@ $router->get('/api/teacher/courses', [\App\Controllers\TeacherController:
|
||||
$router->post('/api/teacher/courses', [\App\Controllers\TeacherController::class, 'addCourse'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/teacher/lessons', [\App\Controllers\TeacherController::class, 'addLesson'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
|
||||
// Dual Video Storage & Bunny.net Stream Routes (API-Driven)
|
||||
// Dual Video Storage & Cloudflare R2 / HLS Stream Routes (API-Driven)
|
||||
$router->post('/api/teacher/videos/upload-direct', [\App\Controllers\VideoController::class, 'uploadDirect'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/teacher/videos/bunny-create', [\App\Controllers\VideoController::class, 'createBunnyVideo'],[\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/teacher/videos/bunny-link', [\App\Controllers\VideoController::class, 'linkBunnyLesson'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoController::class, 'saveCheckpoint'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo']);
|
||||
$router->get('/api/videos/hls/{uuid}/{file}', [\App\Controllers\VideoController::class, 'streamHls']);
|
||||
$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/webhooks/bunny', [\App\Controllers\VideoController::class, 'handleBunnyWebhook']);
|
||||
|
||||
// Student & Teacher Chat Routes (API-Driven, Authenticated)
|
||||
$router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
|
||||
Reference in New Issue
Block a user