Files
saqel/backend/app/Views/CurriculumStudio.php
T

504 lines
28 KiB
PHP

<?php
namespace App\Views;
use App\Services\CurriculumService;
class CurriculumStudio
{
public static function render(): string
{
$tree = CurriculumService::getCurriculumTree();
$treeJson = json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
$serverStorageDir = realpath(__DIR__ . '/../../storage/curriculum') ?: (dirname(__DIR__, 2) . '/storage/curriculum');
ob_start();
?>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>استوديو إدارة وفهرسة المناهج الحية — صَقِل Enterprise</title>
<link rel="icon" type="image/jpeg" href="/assets/images/saqel_logo.jpg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Alexandria:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<script>
window.CURRICULUM_TREE = <?= $treeJson ?>;
window.SERVER_STORAGE_DIR = "<?= addslashes($serverStorageDir) ?>";
</script>
<style>
:root {
--bg-base: #0B0F19;
--bg-card: rgba(22, 27, 34, 0.85);
--border: rgba(255, 255, 255, 0.08);
--accent-cyan: #00F5D4;
--accent-gold: #FFD166;
--accent-purple: #7B2CBF;
--text-primary: #F8FAFC;
--text-secondary: #94A3B8;
--text-muted: #64748B;
}
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Alexandria', sans-serif; }
body { background-color: var(--bg-base); color: var(--text-primary); min-height: 100vh; }
header {
position: sticky; top: 0; z-index: 100;
background: rgba(11, 15, 25, 0.85); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border); padding: 12px 24px;
}
.header-content { max-width: 1400px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }
.container { max-width: 1400px; margin: 24px auto; padding: 0 20px; }
.main-layout { display: grid; grid-template-columns: 380px 1fr; gap: 24px; }
@media (max-width: 900px) { .main-layout { grid-template-columns: 1fr; } }
.tree-sidebar {
background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; padding: 20px;
max-height: calc(100vh - 120px); overflow-y: auto;
}
.tree-node { margin-bottom: 14px; }
.tree-header {
font-size: 13px; font-weight: 800; color: var(--accent-gold); display: flex; align-items: center; gap: 6px;
padding: 8px 12px; background: rgba(255, 209, 102, 0.08); border-radius: 10px; cursor: pointer;
}
.tree-subject {
font-size: 12.5px; font-weight: 700; color: var(--accent-cyan); margin: 6px 12px; padding: 4px 8px;
cursor: pointer; display: flex; align-items: center; gap: 6px;
}
.tree-unit {
font-size: 12px; font-weight: 600; color: var(--text-secondary); margin: 4px 20px; padding: 3px 6px;
}
.tree-lesson {
font-size: 11.5px; color: var(--text-muted); margin: 3px 30px; padding: 6px 10px; border-radius: 8px;
cursor: pointer; transition: all 0.2s;
}
.tree-lesson:hover, .tree-lesson.active {
background: rgba(0, 245, 212, 0.15); color: var(--accent-cyan); font-weight: 700;
}
.workspace-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; padding: 26px;
backdrop-filter: blur(16px);
}
.btn-primary {
background: linear-gradient(135deg, #0284C7, #0369A1); color: #FFF; border: none; padding: 10px 20px;
border-radius: 12px; font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s;
display: inline-flex; align-items: center; gap: 8px;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(2, 132, 199, 0.4); }
.search-box {
width: 100%; background: rgba(0,0,0,0.4); border: 1px solid var(--border); border-radius: 12px;
padding: 10px 16px; color: #FFF; font-size: 13px; margin-bottom: 16px; outline: none;
}
.search-box:focus { border-color: var(--accent-cyan); }
.md-editor {
width: 100%; height: 480px; background: rgba(0,0,0,0.5); border: 1px solid var(--border);
border-radius: 14px; padding: 16px; color: #F1F5F9; font-family: monospace; font-size: 13px;
line-height: 1.7; resize: vertical; outline: none;
}
.server-path-box {
background: rgba(0,0,0,0.4); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px;
font-size: 11.5px; font-family: monospace; color: var(--accent-cyan); margin-bottom: 14px;
display: flex; align-items: center; justify-content: space-between;
}
</style>
</head>
<body>
<header>
<div class="header-content">
<div style="display: flex; align-items: center; gap: 10px;">
<img src="/assets/images/saqel_logo.jpg" alt="صَقِل" style="width: 36px; height: 36px; border-radius: 10px;">
<span style="font-weight: 800; font-size: 18px; color: #FFF;">صَقِل Enterprise</span>
<span style="font-size: 11px; font-weight: 700; color: var(--accent-gold); background: rgba(255,209,102,0.12); padding: 2px 10px; border-radius: 980px;">استوديو تفريغ وفهرسة المناهج الحية 📚</span>
</div>
<div style="display: flex; gap: 10px;">
<a href="/teacher" style="font-size: 12px; color: var(--accent-cyan); text-decoration: none; border: 1px solid rgba(0,245,212,0.3); padding: 5px 14px; border-radius: 980px;">استوديو المعلم 👨‍🏫</a>
<a href="/student" style="font-size: 12px; color: var(--text-muted); text-decoration: none; border: 1px solid var(--border); padding: 5px 14px; border-radius: 980px;">بوابة الطالب 🎓</a>
</div>
</div>
</header>
<div class="container">
<!-- Live PDF Ingestion Bar -->
<div class="workspace-card" style="margin-bottom: 24px; background: linear-gradient(135deg, rgba(30, 41, 59, 0.7), rgba(15, 23, 42, 0.9));">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px;">
<div>
<h2 style="font-size: 20px; font-weight: 900; color: #FFF; margin-bottom: 4px;">رفع وتفريغ كتب المناهج الوزارية (PDF ⟵ Markdown Tree) 📑</h2>
<p style="font-size: 12.5px; color: var(--text-secondary);" id="upload_status_desc">اختر ملف كتاب المنهاج (PDF) ليقوم الخادم بتفريغه وفهرسة وحداته ودروسه الحقيقية على القرص وتوليد ملفات المارك داون.</p>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="file" id="curriculum_pdf_input" accept=".pdf" style="display: none;" onchange="handleRealPdfUpload(this)">
<button type="button" id="btn_upload_pdf" onclick="document.getElementById('curriculum_pdf_input').click()" class="btn-primary">
<span>📤 رفع كتاب المنهاج PDF الحقيقي</span>
</button>
</div>
</div>
</div>
<div class="main-layout">
<!-- Sidebar: Hierarchical Curriculum Tree Explorer -->
<div class="tree-sidebar">
<input type="text" class="search-box" id="curriculum_search_input" placeholder="🔍 ابحث في المناهج والوحدات ونتاجات التعلم..." oninput="handleCurriculumSearch(this.value)">
<div id="curriculum_tree_container">
<!-- Rendered Dynamically from Real Storage -->
</div>
</div>
<!-- Workspace: Markdown Editor & Extraction Inspector -->
<div class="workspace-card" id="workspace_content_area">
<div id="empty_selection_view" style="display: none; text-align: center; padding: 60px 20px;">
<div style="font-size: 48px; margin-bottom: 12px;">📚</div>
<h3 style="font-size: 18px; font-weight: 800; color: #FFF; margin-bottom: 8px;">لا توجد مناهج مضافة حالياً</h3>
<p style="font-size: 13px; color: var(--text-muted); max-width: 480px; margin: 0 auto;">ارفع كتاب المنهاج PDF لبناء الشجرة وتفريغ الدروس بالمارك داون على السيرفر.</p>
</div>
<div id="active_lesson_view" style="display: none;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 10px;">
<div>
<span style="font-size: 11px; font-weight: 800; color: var(--accent-cyan); background: rgba(0,245,212,0.1); padding: 2px 8px; border-radius: 6px;" id="current_breadcrumb"></span>
<h3 style="font-size: 18px; font-weight: 900; color: #FFF; margin-top: 6px;" id="current_lesson_title_display"></h3>
</div>
<button type="button" onclick="saveActiveMarkdown()" class="btn-primary" style="background: linear-gradient(135deg, #10B981, #059669);">
💾 حفظ التعديلات على السيرفر
</button>
</div>
<!-- Exact Server Storage Path Readout -->
<div class="server-path-box">
<span>📁 مسار الملف على السيرفر: <strong id="server_file_path_display" style="color: #FFF;"></strong></span>
<span style="color: var(--accent-gold); font-size: 11px;">(ملف Markdown حي على القرص)</span>
</div>
<div style="margin-bottom: 14px;">
<span style="font-size: 12px; font-weight: 700; color: var(--text-muted); display: block; margin-bottom: 6px;">نتاجات التعلم والمفاهيم المستهدفة:</span>
<div id="current_outcomes_tags" style="display: flex; gap: 8px; flex-wrap: wrap;"></div>
</div>
<!-- Markdown Content Area -->
<textarea id="lesson_markdown_editor" class="md-editor"></textarea>
</div>
</div>
</div>
</div>
<script>
let currentFilePath = '';
document.addEventListener('DOMContentLoaded', () => {
renderCurriculumTree(window.CURRICULUM_TREE);
});
function renderCurriculumTree(tree) {
const container = document.getElementById('curriculum_tree_container');
const entries = Object.entries(tree || {});
if (entries.length === 0) {
container.innerHTML = `
<div style="padding: 40px 20px; text-align: center; color: var(--text-muted); font-size: 13px;">
<div style="font-size: 32px; margin-bottom: 8px;">📂</div>
<strong>لا توجد مناهج مفهرسة بعد</strong>
<p style="margin-top: 4px;">ارفع كتاب المنهاج الوزاري (PDF) عبر الزر بالأعلى ليتم بناء الشجرة وفهرستها تلقائياً.</p>
</div>
`;
document.getElementById('empty_selection_view').style.display = 'block';
document.getElementById('active_lesson_view').style.display = 'none';
return;
}
let html = '';
for (const [gradeKey, grade] of entries) {
html += `
<div class="tree-node">
<div class="tree-header">📁 ${escapeHtml(grade.name)}</div>
`;
for (const [subKey, sub] of Object.entries(grade.subjects || {})) {
html += `<div class="tree-subject">📖 ${escapeHtml(sub.name)}</div>`;
for (const [semKey, sem] of Object.entries(sub.semesters || {})) {
for (const [unitKey, unit] of Object.entries(sem.units || {})) {
html += `<div class="tree-unit">🔹 ${escapeHtml(unit.name)}</div>`;
for (const les of (unit.lessons || [])) {
const activeClass = (les.file === currentFilePath) ? ' active' : '';
html += `
<div class="tree-lesson${activeClass}" onclick="selectLesson('${les.file}', '${escapeHtml(les.title)}', '${escapeHtml(grade.name)} ⟵ ${escapeHtml(sub.name)} ⟵ ${escapeHtml(unit.name)}', ${JSON.stringify(les.outcomes || []).replace(/"/g, '&quot;')})">
📄 ${escapeHtml(les.title)}
</div>
`;
}
}
// NEW: Render Resources (Minhaji Style Categories)
if (sem.resources && Object.keys(sem.resources).length > 0) {
html += `<div style="margin-top: 15px; margin-bottom: 5px; font-weight: bold; color: var(--text-secondary); font-size: 11px; padding-right: 15px;">📂 مصادر المادة الإضافية:</div>`;
html += `<div style="display: flex; flex-wrap: wrap; gap: 6px; padding-right: 15px; margin-bottom: 10px;">`;
for (const [resKey, resourceGroup] of Object.entries(sem.resources)) {
if (!resourceGroup.items || resourceGroup.items.length === 0) continue;
// Hexagon / Badge Style
html += `<div style="flex: 1 1 45%; background: var(--bg-hover); border: 1px solid var(--border-color); border-radius: 6px; padding: 8px; text-align: center; cursor: pointer; transition: all 0.2s;" onmouseover="this.style.background='rgba(255,209,102,0.1)'" onmouseout="this.style.background='var(--bg-hover)'">
<div style="font-size: 14px; margin-bottom: 4px;">📌</div>
<div style="font-size: 10px; font-weight: bold; color: var(--text-primary);">${escapeHtml(resourceGroup.name)}</div>
</div>`;
// Render actual files below the badge (hidden or just list them)
for (const item of resourceGroup.items) {
const activeClass = (item.file === currentFilePath) ? ' active' : '';
html += `
<div style="width: 100%;" class="tree-lesson${activeClass}" onclick="selectLesson('${item.file}', '${escapeHtml(item.title)}', '${escapeHtml(grade.name)} ⟵ ${escapeHtml(sub.name)} ⟵ ${escapeHtml(resourceGroup.name)}', ['مرفق إضافي'])">
📎 ${escapeHtml(item.title)}
</div>
`;
}
}
html += `</div>`;
}
}
}
html += `</div>`;
}
container.innerHTML = html;
// Auto-select first lesson if none selected
if (!currentFilePath) {
const firstGrade = Object.values(tree)[0];
const firstSub = firstGrade?.subjects ? Object.values(firstGrade.subjects)[0] : null;
const firstSem = firstSub?.semesters ? Object.values(firstSub.semesters)[0] : null;
const firstUnit = firstSem?.units ? Object.values(firstSem.units)[0] : null;
const firstLes = firstUnit?.lessons ? firstUnit.lessons[0] : null;
if (firstLes) {
selectLesson(firstLes.file, firstLes.title, `${firstGrade.name} ⟵ ${firstSub.name} ⟵ ${firstUnit.name}`, firstLes.outcomes || []);
}
}
}
async function selectLesson(filePath, title, breadcrumb, outcomes) {
currentFilePath = filePath;
document.getElementById('empty_selection_view').style.display = 'none';
document.getElementById('active_lesson_view').style.display = 'block';
document.getElementById('current_breadcrumb').textContent = breadcrumb;
document.getElementById('current_lesson_title_display').textContent = title;
document.getElementById('server_file_path_display').textContent = `backend/storage/curriculum/${filePath}`;
// Render Outcomes
const tags = document.getElementById('current_outcomes_tags');
tags.innerHTML = (outcomes || []).map(o => `
<span style="font-size: 11px; font-weight: 700; background: rgba(255,209,102,0.12); color: var(--accent-gold); border: 1px solid rgba(255,209,102,0.3); padding: 3px 10px; border-radius: 980px;">
🎯 ${escapeHtml(o)}
</span>
`).join('');
// Highlight Active in Sidebar
document.querySelectorAll('.tree-lesson').forEach(el => el.classList.remove('active'));
event?.currentTarget?.classList.add('active');
// Fetch Real Markdown from Server
try {
const res = await fetch(`/api/curriculum/lesson?file=${encodeURIComponent(filePath)}`);
const data = await res.json();
if (res.ok && data.status === 'success') {
document.getElementById('lesson_markdown_editor').value = data.content || '';
if (data.server_full_path) {
document.getElementById('server_file_path_display').textContent = data.server_full_path;
}
}
} catch (e) {
console.error('Fetch markdown error:', e);
}
}
async function handleRealPdfUpload(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const btn = document.getElementById('btn_upload_pdf');
const desc = document.getElementById('upload_status_desc');
btn.disabled = true;
btn.innerHTML = '<span>⏳ جارٍ فك تشفير وتفريغ الكتاب...</span>';
desc.textContent = `جارٍ تفريغ ملف [${file.name}] إلى شجرة ملفات Markdown حقيقية على السيرفر...`;
const formData = new FormData();
formData.append('pdf_file', file);
try {
const res = await fetch('/api/curriculum/upload-pdf', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok && data.status === 'success') {
window.CURRICULUM_TREE = data.tree;
currentFilePath = data.active_file || '';
renderCurriculumTree(data.tree);
if (data.active_file) {
document.getElementById('empty_selection_view').style.display = 'none';
document.getElementById('active_lesson_view').style.display = 'block';
document.getElementById('lesson_markdown_editor').value = data.active_md || '';
document.getElementById('current_lesson_title_display').textContent = data.active_title || 'الدرس المستخرج';
document.getElementById('current_breadcrumb').textContent = data.active_breadcrumb || 'المسار التعليمي';
document.getElementById('server_file_path_display').textContent = `${data.server_storage_dir}/${data.active_file}`;
}
alert(`✅ ${data.message}\n\nتم حفظ ملفات المنهاج على السيرفر في:\n${data.server_storage_dir}`);
desc.textContent = `تم اعتماد المنهاج [${file.name}] بنجاح في قاعدة المعرفة الشجرية!`;
} else {
alert('⚠️ ' + (data.message || 'فشلت معالجة الملف'));
}
} catch (err) {
console.error('PDF Upload error:', err);
alert('حدث خطأ في الاتصال بالخادم أثناء رفع الملف.');
} finally {
btn.disabled = false;
btn.innerHTML = '<span>📤 رفع كتاب المنهاج PDF الحقيقي</span>';
input.value = '';
}
}
async function saveActiveMarkdown() {
if (!currentFilePath) return;
const content = document.getElementById('lesson_markdown_editor').value;
try {
const res = await fetch('/api/curriculum/save-lesson', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: currentFilePath, content: content })
});
const data = await res.json();
if (res.ok && data.status === 'success') {
alert(`✅ تم حفظ واعتماد التعديلات بنجاح في الملف:\n${data.server_full_path || currentFilePath}`);
} else {
alert('⚠️ فشل حفظ الملف');
}
} catch (e) {
alert('تعذر الاتصال بالسيرفر');
}
}
function handleCurriculumSearch(q) {
if (!q || q.trim() === '') {
renderCurriculumTree(window.CURRICULUM_TREE);
return;
}
const qClean = q.toLowerCase().trim();
const filtered = {};
for (const [gKey, grade] of Object.entries(window.CURRICULUM_TREE || {})) {
let hasMatch = false;
const newSubs = {};
for (const [sKey, sub] of Object.entries(grade.subjects || {})) {
const newSems = {};
for (const [semKey, sem] of Object.entries(sub.semesters || {})) {
const newUnits = {};
for (const [uKey, unit] of Object.entries(sem.units || {})) {
const matchedLessons = (unit.lessons || []).filter(l =>
l.title.toLowerCase().includes(qClean) ||
(l.outcomes && l.outcomes.some(o => o.toLowerCase().includes(qClean)))
);
if (matchedLessons.length > 0) {
newUnits[uKey] = { ...unit, lessons: matchedLessons };
hasMatch = true;
}
}
if (Object.keys(newUnits).length > 0) {
newSems[semKey] = { ...sem, units: newUnits };
}
}
if (Object.keys(newSems).length > 0) {
newSubs[sKey] = { ...sub, semesters: newSems };
}
}
if (hasMatch) {
filtered[gKey] = { ...grade, subjects: newSubs };
}
}
renderCurriculumTree(filtered);
}
function escapeHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
</script>
<!-- Upload Progress Modal -->
<div id="upload_progress_overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(10, 15, 26, 0.85); backdrop-filter: blur(8px); z-index: 9999; flex-direction: column; align-items: center; justify-content: center;">
<div style="background: var(--bg-card); padding: 30px; border-radius: 16px; border: 1px solid var(--border-color); width: 400px; max-width: 90%; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,0.5);">
<div id="ai_spinner" style="font-size: 40px; margin-bottom: 15px; animation: pulse 1.5s infinite;">🧠</div>
<h3 style="color: var(--text-primary); margin-bottom: 10px; font-family: 'SF Arabic', sans-serif;">معالجة الذكاء الاصطناعي</h3>
<div style="width: 100%; background: var(--bg-hover); border-radius: 10px; height: 10px; margin-bottom: 15px; overflow: hidden; position: relative;">
<div id="upload_progress_bar" style="height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-gold), #ffed4a); transition: width 0.4s ease;"></div>
</div>
<p id="upload_progress_text" style="color: var(--text-secondary); font-size: 13px; font-weight: bold;">جاري التهيئة...</p>
<p id="upload_progress_percent" style="color: var(--accent-gold); font-size: 12px; margin-top: 5px;">0%</p>
<button id="btn_view_logs" type="button" style="margin-top: 15px; background: transparent; border: 1px solid var(--border-color); color: var(--text-muted); padding: 5px 12px; border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='var(--text-muted)'" onclick="toggleUploadLogs()">🔍 عرض سجل السيرفر (Logs)</button>
<div id="upload_logs_container" style="display: none; margin-top: 15px; width: 100%; text-align: left;">
<textarea id="upload_logs_textarea" readonly style="width: 100%; height: 120px; background: #000; color: #0f0; font-family: monospace; font-size: 11px; border: 1px solid #333; border-radius: 4px; padding: 8px; resize: none; direction: ltr;"></textarea>
</div>
</div>
</div>
<script>
let currentUploadTaskId = null;
function toggleUploadLogs() {
const container = document.getElementById('upload_logs_container');
if (container.style.display === 'none') {
container.style.display = 'block';
fetchUploadLogs();
} else {
container.style.display = 'none';
}
}
async function fetchUploadLogs() {
if (!currentUploadTaskId) return;
try {
const res = await fetch(`/api/curriculum/upload-log?task_id=${currentUploadTaskId}`);
const data = await res.json();
const textarea = document.getElementById('upload_logs_textarea');
if (data.status === 'success') {
textarea.value = data.log || 'No logs generated yet...';
textarea.scrollTop = textarea.scrollHeight;
}
} catch (e) {
console.error(e);
}
}
</script>
<style>
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
</style>
</body>
</html>
<?php
return ob_get_clean();
}
}