diff --git a/public/js/learningHub.js b/public/js/learningHub.js index 7170c7a..0d00972 100644 --- a/public/js/learningHub.js +++ b/public/js/learningHub.js @@ -11,12 +11,10 @@ import { import { esc, sanitizeHtml } from './learningHub/sanitize.js'; import { deleteJson, getJson, sendJson } from './learningHub/api.js'; import { - renderCmsCategoryList, - renderCmsContentEmpty, - renderCmsContentRow, renderOptionRowShell, renderQuestionBlockShell } from './learningHub/cmsRenderer.js'; +import { createCmsController } from './learningHub/cmsController.js'; import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearchHeader } from './learningHub/feedRenderer.js'; import { renderQuizQuestions, renderQuizResultExplanations } from './learningHub/quizRenderer.js'; import { createSlideController } from './learningHub/slideController.js'; @@ -34,6 +32,10 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l hideBusy: function() { hideBusy(); }, showToast: function(message, type) { showToast(message, type); } }); + var cms = createCmsController({ + refreshUserCategories: function() { loadCategories(); }, + showToast: function(message, type) { showToast(message, type); } + }); // ── Load when tab activated ──────────────────────────────── document.addEventListener('tabChanged', function(e) { @@ -89,8 +91,8 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l } // ── CMS events ───────────────────────────── - if (e.target.closest('#btn-lh-refresh-content')) { loadCmsContent(); loadCmsStats(); return; } - if (e.target.closest('#btn-lh-add-cat')) { addCategory(); return; } + if (e.target.closest('#btn-lh-refresh-content')) { cms.loadContent(); cms.loadStats(); return; } + if (e.target.closest('#btn-lh-add-cat')) { cms.addCategory(); return; } if (e.target.closest('#btn-lh-new-content')) { openEditor(null, 'article'); return; } if (e.target.closest('#btn-lh-new-quiz')) { openEditor(null, 'quiz'); return; } if (e.target.closest('#btn-lh-new-pearl')) { openEditor(null, 'pearl'); return; } @@ -141,11 +143,11 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l // CMS delete category var delCat = e.target.closest('.lh-cms-del-cat'); - if (delCat) { deleteCategory(delCat.dataset.id); return; } + if (delCat) { cms.deleteCategory(delCat.dataset.id); return; } // CMS sidebar category filter var cmsCat = e.target.closest('.lh-cms-cat-filter'); - if (cmsCat && cmsCat.dataset.id) { filterCmsContentByCategory(cmsCat.dataset.id); return; } + if (cmsCat && cmsCat.dataset.id) { cms.filterByCategory(cmsCat.dataset.id); return; } // Remove question block var rmQ = e.target.closest('.lh-rm-question'); @@ -164,6 +166,9 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l if (rmOpt) { var row = rmOpt.closest('.lh-option-row'); if (row) { destroyOptionEditor(row); row.remove(); } return; } }); + document.addEventListener('input', function(e) { cms.handleFilterInput(e); }); + document.addEventListener('change', function(e) { cms.handleFilterChange(e); }); + // wireSearch() called after component is in DOM var _searchTimeout = null; function wireSearch() { @@ -506,9 +511,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l } function loadCms() { - loadCmsCategories(); - loadCmsContent(); - loadCmsStats(); + cms.refreshAll(); } // ============================================================ @@ -837,7 +840,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l opt.textContent = categoryName; sel.appendChild(opt); sel.value = String(data.id); - loadCmsCategories(String(data.id)); + cms.loadCategories(String(data.id)); loadCategories(); }) .catch(function(err) { @@ -884,161 +887,6 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } - function loadCmsStats() { - getJson('/api/admin/learning/stats') - .then(function(data) { - if (!data.success) return; - var s = data.stats; - var set = function(id, val) { var el = document.getElementById(id); if (el) el.textContent = val; }; - set('cms-stat-published', s.publishedContent); - set('cms-stat-drafts', s.totalContent - s.publishedContent); - set('cms-stat-categories', s.totalCategories); - set('cms-stat-quizzes', s.totalQuizzes); - set('cms-stat-attempts', s.totalAttempts); - }); - } - - function loadCmsCategories(editorCategoryId, filterCategoryId) { - getJson('/api/admin/learning/categories') - .then(function(data) { - if (!data.success) return; - var container = document.getElementById('lh-cms-categories'); - if (!container) return; - - container.innerHTML = renderCmsCategoryList(data.categories); - - // Update category dropdowns (editor + filter) - var sel = document.getElementById('lh-cms-edit-category'); - if (sel) { - var selectedEditorId = editorCategoryId !== undefined ? editorCategoryId : sel.value; - sel.innerHTML = ''; - data.categories.forEach(function(c) { - sel.innerHTML += ''; - }); - if (selectedEditorId && hasSelectValue(sel, selectedEditorId)) sel.value = selectedEditorId; - } - var filterSel = document.getElementById('cms-filter-category'); - if (filterSel) { - var selectedFilterId = filterCategoryId !== undefined ? filterCategoryId : filterSel.value; - filterSel.innerHTML = ''; - data.categories.forEach(function(c) { - filterSel.innerHTML += ''; - }); - if (selectedFilterId && hasSelectValue(filterSel, selectedFilterId)) filterSel.value = selectedFilterId; - } - setActiveCmsCat((document.getElementById('cms-filter-category') || {}).value || 'all'); - }); - } - - function hasSelectValue(sel, value) { - value = String(value); - for (var i = 0; i < sel.options.length; i++) { - if (sel.options[i].value === value) return true; - } - return false; - } - - function addCategory() { - var input = document.getElementById('lh-cms-cat-name'); - if (!input || !input.value.trim()) { showToast('Enter category name', 'error'); return; } - - sendJson('/api/admin/learning/categories', 'POST', { name: input.value.trim() }) - .then(function(data) { - if (data.success) { - input.value = ''; - loadCmsCategories(); - loadCategories(); // refresh user-facing pills too - showToast('Category created', 'success'); - } else showToast(data.error || 'Failed', 'error'); - }) - .catch(function() { showToast('Request failed', 'error'); }); - } - - function deleteCategory(id) { - if (!window.__confirmDeleteCat) { - window.__confirmDeleteCat = id; - showToast('Click delete again to confirm removing this category', 'info'); - setTimeout(function() { window.__confirmDeleteCat = null; }, 4000); - return; - } - window.__confirmDeleteCat = null; - deleteJson('/api/admin/learning/categories/' + id) - .then(function(data) { - if (data.success) { loadCmsCategories(); loadCategories(); showToast('Category deleted', 'info'); } - else showToast(data.error || 'Failed', 'error'); - }); - } - - function loadCmsContent() { - var container = document.getElementById('lh-cms-content-list'); - if (!container) return; - container.innerHTML = '
Loading...
'; - - getJson('/api/admin/learning/content') - .then(function(data) { - if (!data.success) return; - if (data.content.length === 0) { - container.innerHTML = 'No content yet. Click "New Content" to create.
'; - return; - } - window._cmsContentData = data.content; - renderCmsContentList(data.content); - }); - } - - function renderCmsContentList(items) { - var container = document.getElementById('lh-cms-content-list'); - if (!container) return; - if (!items || items.length === 0) { - container.innerHTML = renderCmsContentEmpty(); - return; - } - - container.innerHTML = items.map(renderCmsContentRow).join(''); - } - - // CMS search & filter - document.addEventListener('input', function(e) { - if (e.target.id === 'cms-search') filterCmsContent(); - }); - document.addEventListener('change', function(e) { - if (e.target.id === 'cms-filter-status' || e.target.id === 'cms-filter-category') { - if (e.target.id === 'cms-filter-category') setActiveCmsCat(e.target.value || 'all'); - filterCmsContent(); - } - }); - - function filterCmsContentByCategory(categoryId) { - var filterSel = document.getElementById('cms-filter-category'); - if (filterSel) filterSel.value = String(categoryId); - setActiveCmsCat(categoryId); - filterCmsContent(); - } - - function setActiveCmsCat(categoryId) { - categoryId = String(categoryId || 'all'); - document.querySelectorAll('.lh-cms-cat-filter').forEach(function(item) { - item.classList.toggle('active', categoryId !== 'all' && item.dataset.id === categoryId); - }); - } - - function filterCmsContent() { - var items = window._cmsContentData || []; - var search = (document.getElementById('cms-search') || {}).value || ''; - var statusFilter = (document.getElementById('cms-filter-status') || {}).value || 'all'; - var catFilter = (document.getElementById('cms-filter-category') || {}).value || 'all'; - - search = search.toLowerCase().trim(); - var filtered = items.filter(function(item) { - if (search && item.title.toLowerCase().indexOf(search) === -1 && (item.subject || '').toLowerCase().indexOf(search) === -1) return false; - if (statusFilter === 'published' && !item.published) return false; - if (statusFilter === 'draft' && item.published) return false; - if (catFilter !== 'all' && String(item.category_id) !== catFilter) return false; - return true; - }); - renderCmsContentList(filtered); - } - // ── Body editor ──────────────────────────────────────────── function initBodyEditor() { var wrap = document.getElementById('lh-body-editor'); @@ -1272,9 +1120,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l hideLoading(); showToast('Content saved', 'success'); closeEditor(); - loadCmsContent(); - loadCmsCategories(); - loadCmsStats(); + cms.refreshAll(); loaded = false; // force refresh user feed }); }) @@ -1345,9 +1191,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l if (data.success) { showToast('Content deleted', 'info'); closeEditor(); - loadCmsContent(); - loadCmsCategories(); - loadCmsStats(); + cms.refreshAll(); loaded = false; } else showToast(data.error || 'Failed', 'error'); }); diff --git a/public/js/learningHub/cmsController.js b/public/js/learningHub/cmsController.js new file mode 100644 index 0000000..c81f1db --- /dev/null +++ b/public/js/learningHub/cmsController.js @@ -0,0 +1,190 @@ +import { deleteJson, getJson, sendJson } from './api.js'; +import { renderCmsCategoryList, renderCmsContentEmpty, renderCmsContentRow } from './cmsRenderer.js'; +import { esc } from './sanitize.js'; + +export function createCmsController(deps) { + var contentData = []; + + function loadStats() { + getJson('/api/admin/learning/stats') + .then(function(data) { + if (!data.success) return; + var s = data.stats; + setText('cms-stat-published', s.publishedContent); + setText('cms-stat-drafts', s.totalContent - s.publishedContent); + setText('cms-stat-categories', s.totalCategories); + setText('cms-stat-quizzes', s.totalQuizzes); + setText('cms-stat-attempts', s.totalAttempts); + }); + } + + function loadCategories(editorCategoryId, filterCategoryId) { + getJson('/api/admin/learning/categories') + .then(function(data) { + if (!data.success) return; + var container = document.getElementById('lh-cms-categories'); + if (!container) return; + + container.innerHTML = renderCmsCategoryList(data.categories); + updateCategorySelects(data.categories, editorCategoryId, filterCategoryId); + setActiveCategory((document.getElementById('cms-filter-category') || {}).value || 'all'); + }); + } + + function addCategory() { + var input = document.getElementById('lh-cms-cat-name'); + if (!input || !input.value.trim()) { deps.showToast('Enter category name', 'error'); return; } + + sendJson('/api/admin/learning/categories', 'POST', { name: input.value.trim() }) + .then(function(data) { + if (data.success) { + input.value = ''; + loadCategories(); + deps.refreshUserCategories(); + deps.showToast('Category created', 'success'); + } else deps.showToast(data.error || 'Failed', 'error'); + }) + .catch(function() { deps.showToast('Request failed', 'error'); }); + } + + function deleteCategory(id) { + if (!window.__confirmDeleteCat) { + window.__confirmDeleteCat = id; + deps.showToast('Click delete again to confirm removing this category', 'info'); + setTimeout(function() { window.__confirmDeleteCat = null; }, 4000); + return; + } + window.__confirmDeleteCat = null; + deleteJson('/api/admin/learning/categories/' + id) + .then(function(data) { + if (data.success) { + loadCategories(); + deps.refreshUserCategories(); + deps.showToast('Category deleted', 'info'); + } else deps.showToast(data.error || 'Failed', 'error'); + }); + } + + function loadContent() { + var container = document.getElementById('lh-cms-content-list'); + if (!container) return; + container.innerHTML = 'Loading...
'; + + getJson('/api/admin/learning/content') + .then(function(data) { + if (!data.success) return; + contentData = data.content || []; + if (contentData.length === 0) { + container.innerHTML = 'No content yet. Click "New Content" to create.
'; + return; + } + renderContentList(contentData); + }); + } + + function handleFilterInput(e) { + if (e.target.id === 'cms-search') filterContent(); + } + + function handleFilterChange(e) { + if (e.target.id !== 'cms-filter-status' && e.target.id !== 'cms-filter-category') return; + if (e.target.id === 'cms-filter-category') setActiveCategory(e.target.value || 'all'); + filterContent(); + } + + function filterByCategory(categoryId) { + var filterSel = document.getElementById('cms-filter-category'); + if (filterSel) filterSel.value = String(categoryId); + setActiveCategory(categoryId); + filterContent(); + } + + function refreshAll() { + loadContent(); + loadCategories(); + loadStats(); + } + + function renderContentList(items) { + var container = document.getElementById('lh-cms-content-list'); + if (!container) return; + if (!items || items.length === 0) { + container.innerHTML = renderCmsContentEmpty(); + return; + } + + container.innerHTML = items.map(renderCmsContentRow).join(''); + } + + function filterContent() { + var search = (document.getElementById('cms-search') || {}).value || ''; + var statusFilter = (document.getElementById('cms-filter-status') || {}).value || 'all'; + var catFilter = (document.getElementById('cms-filter-category') || {}).value || 'all'; + + search = search.toLowerCase().trim(); + var filtered = contentData.filter(function(item) { + var title = (item.title || '').toLowerCase(); + var subject = (item.subject || '').toLowerCase(); + if (search && title.indexOf(search) === -1 && subject.indexOf(search) === -1) return false; + if (statusFilter === 'published' && !item.published) return false; + if (statusFilter === 'draft' && item.published) return false; + if (catFilter !== 'all' && String(item.category_id) !== catFilter) return false; + return true; + }); + renderContentList(filtered); + } + + return { + addCategory: addCategory, + deleteCategory: deleteCategory, + filterByCategory: filterByCategory, + handleFilterChange: handleFilterChange, + handleFilterInput: handleFilterInput, + loadCategories: loadCategories, + loadContent: loadContent, + loadStats: loadStats, + refreshAll: refreshAll + }; +} + +function updateCategorySelects(categories, editorCategoryId, filterCategoryId) { + var sel = document.getElementById('lh-cms-edit-category'); + if (sel) { + var selectedEditorId = editorCategoryId !== undefined ? editorCategoryId : sel.value; + sel.innerHTML = ''; + categories.forEach(function(c) { + sel.innerHTML += ''; + }); + if (selectedEditorId && hasSelectValue(sel, selectedEditorId)) sel.value = selectedEditorId; + } + + var filterSel = document.getElementById('cms-filter-category'); + if (filterSel) { + var selectedFilterId = filterCategoryId !== undefined ? filterCategoryId : filterSel.value; + filterSel.innerHTML = ''; + categories.forEach(function(c) { + filterSel.innerHTML += ''; + }); + if (selectedFilterId && hasSelectValue(filterSel, selectedFilterId)) filterSel.value = selectedFilterId; + } +} + +function setText(id, val) { + var el = document.getElementById(id); + if (el) el.textContent = val; +} + +function setActiveCategory(categoryId) { + categoryId = String(categoryId || 'all'); + document.querySelectorAll('.lh-cms-cat-filter').forEach(function(item) { + item.classList.toggle('active', categoryId !== 'all' && item.dataset.id === categoryId); + }); +} + +function hasSelectValue(sel, value) { + value = String(value); + for (var i = 0; i < sel.options.length; i++) { + if (sel.options[i].value === value) return true; + } + return false; +} diff --git a/test/learning-hub-ai-category.test.js b/test/learning-hub-ai-category.test.js index 855bcfd..2a34fc7 100644 --- a/test/learning-hub-ai-category.test.js +++ b/test/learning-hub-ai-category.test.js @@ -27,7 +27,7 @@ test('Learning Hub applies AI-generated category names to the editor', () => { assert.match(frontend, /function applyAiCategory\(categoryName\)/); assert.match(frontend, /sendJson\('\/api\/admin\/learning\/categories', 'POST', \{ name: categoryName \}\)/); assert.match(frontend, /findCategoryOption\(sel, categoryName\)/); - assert.match(frontend, /loadCmsCategories\(\);/); + assert.match(frontend, /cms\.loadCategories\(String\(data\.id\)\)/); assert.match(frontend, /loadCategories\(\);/); }); diff --git a/test/learning-hub-editor.test.js b/test/learning-hub-editor.test.js index 3937ba9..c0d07af 100644 --- a/test/learning-hub-editor.test.js +++ b/test/learning-hub-editor.test.js @@ -9,6 +9,7 @@ function read(relativePath) { const source = read('public/js/learningHub.js'); const apiModule = read('public/js/learningHub/api.js'); +const cmsControllerModule = read('public/js/learningHub/cmsController.js'); const cmsRendererModule = read('public/js/learningHub/cmsRenderer.js'); const editorModule = read('public/js/learningHub/tiptapEditor.js'); const feedRendererModule = read('public/js/learningHub/feedRenderer.js'); @@ -75,22 +76,38 @@ test('Learning Hub CMS renderers are isolated from API and editor flows', () => }); test('Learning Hub preserves AI-created category selection before save', () => { - assert.match(source, /function loadCmsCategories\(editorCategoryId, filterCategoryId\)/); - assert.match(source, /var selectedEditorId = editorCategoryId !== undefined \? editorCategoryId : sel\.value/); - assert.match(source, /if \(selectedEditorId && hasSelectValue\(sel, selectedEditorId\)\) sel\.value = selectedEditorId/); - assert.match(source, /loadCmsCategories\(String\(data\.id\)\)/); + assert.match(cmsControllerModule, /function loadCategories\(editorCategoryId, filterCategoryId\)/); + assert.match(cmsControllerModule, /var selectedEditorId = editorCategoryId !== undefined \? editorCategoryId : sel\.value/); + assert.match(cmsControllerModule, /if \(selectedEditorId && hasSelectValue\(sel, selectedEditorId\)\) sel\.value = selectedEditorId/); + assert.match(source, /cms\.loadCategories\(String\(data\.id\)\)/); }); test('Learning Hub CMS sidebar categories filter content list', () => { - assert.match(source, /filterCmsContentByCategory\(cmsCat\.dataset\.id\)/); - assert.match(source, /function filterCmsContentByCategory\(categoryId\)/); - assert.match(source, /function setActiveCmsCat\(categoryId\)/); - assert.match(source, /item\.classList\.toggle\('active'/); + assert.match(source, /cms\.filterByCategory\(cmsCat\.dataset\.id\)/); + assert.match(cmsControllerModule, /function filterByCategory\(categoryId\)/); + assert.match(cmsControllerModule, /function setActiveCategory\(categoryId\)/); + assert.match(cmsControllerModule, /item\.classList\.toggle\('active'/); }); test('Learning Hub refreshes CMS category counts after content changes', () => { - assert.match(source, /saveQuestions\(contentId, questions, 0, function\(\) \{[\s\S]*?loadCmsContent\(\);[\s\S]*?loadCmsCategories\(\);[\s\S]*?loadCmsStats\(\);/); - assert.match(source, /showToast\('Content deleted', 'info'\);[\s\S]*?loadCmsContent\(\);[\s\S]*?loadCmsCategories\(\);[\s\S]*?loadCmsStats\(\);/); + assert.match(source, /saveQuestions\(contentId, questions, 0, function\(\) \{[\s\S]*?cms\.refreshAll\(\);/); + assert.match(source, /showToast\('Content deleted', 'info'\);[\s\S]*?cms\.refreshAll\(\);/); + assert.match(cmsControllerModule, /function refreshAll\(\) \{[\s\S]*?loadContent\(\);[\s\S]*?loadCategories\(\);[\s\S]*?loadStats\(\);/); +}); + +test('Learning Hub CMS controller owns admin list operations', () => { + assert.match(source, /from '\.\/learningHub\/cmsController\.js'/); + assert.match(source, /var cms = createCmsController\(/); + assert.match(source, /cms\.loadContent\(\); cms\.loadStats\(\)/); + assert.match(source, /cms\.addCategory\(\)/); + assert.match(source, /cms\.deleteCategory\(delCat\.dataset\.id\)/); + assert.match(cmsControllerModule, /export function createCmsController\(deps\)/); + assert.match(cmsControllerModule, /function loadStats\(\)/); + assert.match(cmsControllerModule, /function loadCategories\(editorCategoryId, filterCategoryId\)/); + assert.match(cmsControllerModule, /function loadContent\(\)/); + assert.match(cmsControllerModule, /function handleFilterInput\(e\)/); + assert.doesNotMatch(cmsControllerModule, /window\.Tiptap|ai-generate|FormData/); + assert.doesNotMatch(source, /function loadCmsStats\(\)|function loadCmsCategories\(|function loadCmsContent\(\)/); }); test('Learning Hub slide controller owns preview and PPTX flow', () => {