extract learning hub ai panel controller

This commit is contained in:
Daniel 2026-05-08 16:54:48 +02:00
parent 48931e8024
commit e5e0435b64
5 changed files with 289 additions and 103 deletions

View file

@ -10,6 +10,7 @@ import {
} from './learningHub/tiptapEditor.js';
import { esc, sanitizeHtml } from './learningHub/sanitize.js';
import { deleteJson, getJson, sendJson } from './learningHub/api.js';
import { createAiPanelController } from './learningHub/aiPanelController.js';
import {
renderOptionRowShell,
renderQuestionBlockShell
@ -44,6 +45,12 @@ import { createWebdavController } from './learningHub/webdavController.js';
showToast: function(message, type) { showToast(message, type); }
});
var webdav = createWebdavController({});
var aiPanel = createAiPanelController({
browseWebdav: function(path) { webdav.browse(path); },
getEditorType: function() { return (document.getElementById('lh-cms-edit-type') || {}).value; },
getWebdavPath: function() { return webdav.getCurrentPath(); },
setWebdavPath: function(path) { webdav.setCurrentPath(path); }
});
// ── Load when tab activated ────────────────────────────────
document.addEventListener('tabChanged', function(e) {
@ -117,8 +124,8 @@ import { createWebdavController } from './learningHub/webdavController.js';
if (e.target.closest('#btn-lh-download-pptx')) { slides.downloadPptx(); return; }
// AI panel
if (e.target.closest('#btn-lh-ai-open')) { openAiPanel(); return; }
if (e.target.closest('#btn-lh-ai-close')) { closeAiPanel(); return; }
if (e.target.closest('#btn-lh-ai-open')) { aiPanel.open(); return; }
if (e.target.closest('#btn-lh-ai-close')) { aiPanel.close(); return; }
if (e.target.closest('#btn-lh-ai-generate')) { runAiGenerate(); return; }
if (e.target.closest('#btn-lh-ai-refine-body')) { toggleRefineBar(); return; }
if (e.target.closest('#btn-lh-refine-submit')) { submitRefineBody(); return; }
@ -128,7 +135,7 @@ import { createWebdavController } from './learningHub/webdavController.js';
// AI tabs
var aiTab = e.target.closest('.lh-ai-tab');
if (aiTab && aiTab.dataset.aitab) { switchAiTab(aiTab.dataset.aitab); return; }
if (aiTab && aiTab.dataset.aitab) { aiPanel.switchTab(aiTab.dataset.aitab); return; }
// WebDAV list items
var wdItem = e.target.closest('.lh-webdav-item');
@ -438,104 +445,6 @@ import { createWebdavController } from './learningHub/webdavController.js';
// AI GENERATE PANEL
// ============================================================
function openAiPanel() {
var panel = document.getElementById('lh-ai-panel');
if (!panel) return;
panel.classList.remove('hidden');
populateAiModelSelect();
// Sync content type selector with the editor's current type
var editorType = (document.getElementById('lh-cms-edit-type') || {}).value || 'article';
var ctype = document.getElementById('lh-ai-ctype');
if (ctype) ctype.value = editorType;
updateAiOptions(editorType);
// Hide WebDAV tab if Nextcloud not configured
getJson('/api/auth/me')
.then(function(d) {
var tab = document.getElementById('lh-ai-tab-webdav');
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
if (d.user && d.user.webdav_learning_path) webdav.setCurrentPath(d.user.webdav_learning_path || '/');
});
// Wire ctype change once (use a flag so it doesn't stack on re-opens)
var ctypeEl = document.getElementById('lh-ai-ctype');
var toggleEl = document.getElementById('lh-ai-q-toggle');
if (ctypeEl && !ctypeEl._wired) {
ctypeEl.addEventListener('change', function() { updateAiOptions(ctypeEl.value); });
ctypeEl._wired = true;
}
if (toggleEl && !toggleEl._wired) {
toggleEl.addEventListener('change', function() { updateAiOptions((document.getElementById('lh-ai-ctype') || {}).value); });
toggleEl._wired = true;
}
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function updateAiOptions(type) {
var wordsField = document.getElementById('lh-ai-words-field');
var slidesField = document.getElementById('lh-ai-slides-field');
var quizRow = document.getElementById('lh-ai-quiz-row');
var cardTitle = document.getElementById('lh-ai-quiz-card-title');
var toggleLabel = document.getElementById('lh-ai-q-toggle-label');
var toggleCb = document.getElementById('lh-ai-q-toggle');
var countWrap = document.getElementById('lh-ai-q-count-wrap');
// Use style.display directly — classList.toggle('hidden') doesn't work because
// .lh-ai-opt-field { display:flex } is defined later in CSS and overrides .hidden
if (wordsField) wordsField.style.display = (type === 'quiz' || type === 'presentation') ? 'none' : 'flex';
if (slidesField) slidesField.style.display = (type === 'presentation') ? 'flex' : 'none';
if (type === 'quiz') {
// Quiz: questions are the whole point — show the card prominently, no toggle needed
if (quizRow) quizRow.style.display = 'block';
if (cardTitle) cardTitle.textContent = 'Quiz Questions';
if (toggleLabel) toggleLabel.style.display = 'none';
if (toggleCb) { toggleCb.checked = true; toggleCb.disabled = true; }
if (countWrap) countWrap.style.display = 'flex';
} else if (type === 'presentation') {
// Presentation: optional questions (same as article)
if (quizRow) quizRow.style.display = 'block';
if (cardTitle) cardTitle.textContent = 'Quiz Questions (optional)';
if (toggleLabel) toggleLabel.style.display = 'flex';
if (toggleCb) toggleCb.disabled = false;
var checkedP = toggleCb ? toggleCb.checked : false;
if (countWrap) countWrap.style.display = checkedP ? 'flex' : 'none';
} else {
// Article / pearl: optional questions via toggle
if (quizRow) quizRow.style.display = 'block';
if (cardTitle) cardTitle.textContent = 'Quiz Questions (optional)';
if (toggleLabel) toggleLabel.style.display = 'flex';
if (toggleCb) toggleCb.disabled = false;
var checked = toggleCb ? toggleCb.checked : false;
if (countWrap) countWrap.style.display = checked ? 'flex' : 'none';
}
}
function closeAiPanel() {
var panel = document.getElementById('lh-ai-panel');
if (panel) panel.classList.add('hidden');
}
function populateAiModelSelect() {
var sel = document.getElementById('lh-ai-model');
if (!sel || sel.options.length > 1) return;
if (typeof window._buildModelOptions === 'function') {
window._buildModelOptions(sel);
}
}
function switchAiTab(tabName) {
document.querySelectorAll('.lh-ai-tab').forEach(function(t) { t.classList.toggle('active', t.dataset.aitab === tabName); });
document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); });
var tp = document.getElementById('lh-ai-tp-' + tabName);
if (tp) tp.classList.remove('hidden');
if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) {
webdav.browse(webdav.getCurrentPath());
}
}
// ── Generate ────────────────────────────────────────────────
function runAiGenerate() {
var activeTab = document.querySelector('.lh-ai-tab.active');
@ -596,7 +505,7 @@ import { createWebdavController } from './learningHub/webdavController.js';
// Presentation returns marpMarkdown directly; others return content{}
var payload = data.contentType === 'presentation' ? data : data.content;
return applyAiContent(payload, contentType).then(function() {
closeAiPanel();
aiPanel.close();
showToast('Content generated! Review and save.', 'success');
});
})

View file

@ -0,0 +1,103 @@
import { getJson } from './api.js';
export function createAiPanelController(deps) {
function open() {
var panel = document.getElementById('lh-ai-panel');
if (!panel) return;
panel.classList.remove('hidden');
populateModelSelect();
var editorType = deps.getEditorType() || 'article';
var ctype = document.getElementById('lh-ai-ctype');
if (ctype) ctype.value = editorType;
updateOptions(editorType);
getJson('/api/auth/me')
.then(function(d) {
var tab = document.getElementById('lh-ai-tab-webdav');
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
if (d.user && d.user.webdav_learning_path) deps.setWebdavPath(d.user.webdav_learning_path || '/');
});
wireOptionControls();
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function close() {
var panel = document.getElementById('lh-ai-panel');
if (panel) panel.classList.add('hidden');
}
function populateModelSelect() {
var sel = document.getElementById('lh-ai-model');
if (!sel || sel.options.length > 1) return;
if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel);
}
function switchTab(tabName) {
document.querySelectorAll('.lh-ai-tab').forEach(function(t) {
t.classList.toggle('active', t.dataset.aitab === tabName);
});
document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); });
var tp = document.getElementById('lh-ai-tp-' + tabName);
if (tp) tp.classList.remove('hidden');
if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) {
deps.browseWebdav(deps.getWebdavPath());
}
}
function updateOptions(type) {
applyAiOptionState(type, {
wordsField: document.getElementById('lh-ai-words-field'),
slidesField: document.getElementById('lh-ai-slides-field'),
quizRow: document.getElementById('lh-ai-quiz-row'),
cardTitle: document.getElementById('lh-ai-quiz-card-title'),
toggleLabel: document.getElementById('lh-ai-q-toggle-label'),
toggleCb: document.getElementById('lh-ai-q-toggle'),
countWrap: document.getElementById('lh-ai-q-count-wrap')
});
}
function wireOptionControls() {
var ctypeEl = document.getElementById('lh-ai-ctype');
var toggleEl = document.getElementById('lh-ai-q-toggle');
if (ctypeEl && !ctypeEl._wired) {
ctypeEl.addEventListener('change', function() { updateOptions(ctypeEl.value); });
ctypeEl._wired = true;
}
if (toggleEl && !toggleEl._wired) {
toggleEl.addEventListener('change', function() {
updateOptions((document.getElementById('lh-ai-ctype') || {}).value);
});
toggleEl._wired = true;
}
}
return {
close: close,
open: open,
switchTab: switchTab,
updateOptions: updateOptions
};
}
export function applyAiOptionState(type, els) {
if (els.wordsField) els.wordsField.style.display = (type === 'quiz' || type === 'presentation') ? 'none' : 'flex';
if (els.slidesField) els.slidesField.style.display = type === 'presentation' ? 'flex' : 'none';
if (type === 'quiz') {
if (els.quizRow) els.quizRow.style.display = 'block';
if (els.cardTitle) els.cardTitle.textContent = 'Quiz Questions';
if (els.toggleLabel) els.toggleLabel.style.display = 'none';
if (els.toggleCb) { els.toggleCb.checked = true; els.toggleCb.disabled = true; }
if (els.countWrap) els.countWrap.style.display = 'flex';
return;
}
if (els.quizRow) els.quizRow.style.display = 'block';
if (els.cardTitle) els.cardTitle.textContent = 'Quiz Questions (optional)';
if (els.toggleLabel) els.toggleLabel.style.display = 'flex';
if (els.toggleCb) els.toggleCb.disabled = false;
var checked = els.toggleCb ? els.toggleCb.checked : false;
if (els.countWrap) els.countWrap.style.display = checked ? 'flex' : 'none';
}

View file

@ -0,0 +1,156 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { JSDOM } = require('jsdom');
async function loadAiPanelController() {
return import(pathToFileURL(path.join(__dirname, '..', 'public/js/learningHub/aiPanelController.js')).href);
}
function makeDom() {
return new JSDOM(`<!doctype html>
<div id="lh-ai-panel" class="hidden"></div>
<select id="lh-ai-model"><option value="">Default</option></select>
<select id="lh-ai-ctype"><option value="article">Article</option><option value="quiz">Quiz</option><option value="presentation">Presentation</option></select>
<div id="lh-ai-words-field"></div>
<div id="lh-ai-slides-field"></div>
<div id="lh-ai-quiz-row"></div>
<div id="lh-ai-quiz-card-title"></div>
<label id="lh-ai-q-toggle-label"><input id="lh-ai-q-toggle" type="checkbox"></label>
<div id="lh-ai-q-count-wrap"></div>
<button id="lh-ai-tab-prompt" class="lh-ai-tab active" data-aitab="prompt"></button>
<button id="lh-ai-tab-webdav" class="lh-ai-tab" data-aitab="webdav"></button>
<div id="lh-ai-tp-prompt" class="lh-ai-tabpanel"></div>
<div id="lh-ai-tp-webdav" class="lh-ai-tabpanel hidden"></div>
<div id="lh-ai-webdav-list"></div>`);
}
function withGlobals(dom, fn) {
const previousDocument = global.document;
const previousWindow = global.window;
const previousFetch = global.fetch;
const previousGetAuthHeaders = global.getAuthHeaders;
global.document = dom.window.document;
global.window = dom.window;
global.getAuthHeaders = function() { return {}; };
return Promise.resolve()
.then(fn)
.finally(function() {
global.document = previousDocument;
global.window = previousWindow;
global.fetch = previousFetch;
global.getAuthHeaders = previousGetAuthHeaders;
});
}
test('Learning Hub AI panel option state matches content type semantics', async () => {
const { applyAiOptionState } = await loadAiPanelController();
const els = {
wordsField: { style: {} },
slidesField: { style: {} },
quizRow: { style: {} },
cardTitle: { textContent: '' },
toggleLabel: { style: {} },
toggleCb: { checked: false, disabled: false },
countWrap: { style: {} }
};
applyAiOptionState('article', els);
assert.equal(els.wordsField.style.display, 'flex');
assert.equal(els.slidesField.style.display, 'none');
assert.equal(els.cardTitle.textContent, 'Quiz Questions (optional)');
assert.equal(els.countWrap.style.display, 'none');
els.toggleCb.checked = true;
applyAiOptionState('presentation', els);
assert.equal(els.wordsField.style.display, 'none');
assert.equal(els.slidesField.style.display, 'flex');
assert.equal(els.toggleCb.disabled, false);
assert.equal(els.countWrap.style.display, 'flex');
els.toggleCb.checked = false;
applyAiOptionState('quiz', els);
assert.equal(els.wordsField.style.display, 'none');
assert.equal(els.slidesField.style.display, 'none');
assert.equal(els.cardTitle.textContent, 'Quiz Questions');
assert.equal(els.toggleLabel.style.display, 'none');
assert.equal(els.toggleCb.checked, true);
assert.equal(els.toggleCb.disabled, true);
assert.equal(els.countWrap.style.display, 'flex');
});
test('Learning Hub AI panel opens with defaults, models, auth state, and stable handlers', async () => {
const dom = makeDom();
await withGlobals(dom, async function() {
const { createAiPanelController } = await loadAiPanelController();
let modelBuilds = 0;
let fetches = 0;
const paths = [];
const panel = document.getElementById('lh-ai-panel');
panel.scrollIntoView = function() {};
window._buildModelOptions = function(sel) {
modelBuilds += 1;
const opt = document.createElement('option');
opt.value = 'model-a';
opt.textContent = 'Model A';
sel.appendChild(opt);
};
global.fetch = function(url) {
fetches += 1;
assert.equal(url, '/api/auth/me');
return Promise.resolve({ json: function() { return Promise.resolve({ user: { nextcloud_url: 'https://cloud.example', webdav_learning_path: '/Learning' } }); } });
};
const controller = createAiPanelController({
browseWebdav: function() {},
getEditorType: function() { return 'presentation'; },
getWebdavPath: function() { return '/Learning'; },
setWebdavPath: function(pathValue) { paths.push(pathValue); }
});
controller.open();
controller.open();
await new Promise(function(resolve) { setImmediate(resolve); });
assert.equal(panel.classList.contains('hidden'), false);
assert.equal(document.getElementById('lh-ai-ctype').value, 'presentation');
assert.equal(document.getElementById('lh-ai-words-field').style.display, 'none');
assert.equal(document.getElementById('lh-ai-slides-field').style.display, 'flex');
assert.equal(document.getElementById('lh-ai-tab-webdav').style.display, '');
assert.deepEqual(paths, ['/Learning', '/Learning']);
assert.equal(modelBuilds, 1);
assert.equal(fetches, 2);
const ctype = document.getElementById('lh-ai-ctype');
ctype.value = 'quiz';
ctype.dispatchEvent(new window.Event('change'));
assert.equal(document.getElementById('lh-ai-q-toggle').disabled, true);
assert.equal(document.getElementById('lh-ai-quiz-card-title').textContent, 'Quiz Questions');
});
});
test('Learning Hub AI panel tab switch lazily browses empty WebDAV panel', async () => {
const dom = makeDom();
await withGlobals(dom, async function() {
const { createAiPanelController } = await loadAiPanelController();
const browsedPaths = [];
const controller = createAiPanelController({
browseWebdav: function(pathValue) { browsedPaths.push(pathValue); },
getEditorType: function() { return 'article'; },
getWebdavPath: function() { return '/Learning/Sub'; },
setWebdavPath: function() {}
});
controller.switchTab('webdav');
assert.equal(document.getElementById('lh-ai-tab-webdav').classList.contains('active'), true);
assert.equal(document.getElementById('lh-ai-tp-webdav').classList.contains('hidden'), false);
assert.deepEqual(browsedPaths, ['/Learning/Sub']);
document.getElementById('lh-ai-webdav-list').innerHTML = '<button class="lh-webdav-item"></button>';
controller.switchTab('webdav');
assert.deepEqual(browsedPaths, ['/Learning/Sub']);
});
});

View file

@ -9,6 +9,7 @@ function read(relativePath) {
const source = read('public/js/learningHub.js');
const apiModule = read('public/js/learningHub/api.js');
const aiPanelControllerModule = read('public/js/learningHub/aiPanelController.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');
@ -164,6 +165,21 @@ test('Learning Hub WebDAV controller owns browser state and rendering', () => {
assert.doesNotMatch(webdavControllerModule, /window\.Tiptap|ai-generate|FormData/);
});
test('Learning Hub AI panel controller owns panel options and source tabs', () => {
assert.match(source, /from '\.\/learningHub\/aiPanelController\.js'/);
assert.match(source, /var aiPanel = createAiPanelController\(/);
assert.match(source, /aiPanel\.open\(\)/);
assert.match(source, /aiPanel\.close\(\)/);
assert.match(source, /aiPanel\.switchTab\(aiTab\.dataset\.aitab\)/);
assert.match(aiPanelControllerModule, /export function createAiPanelController\(deps\)/);
assert.match(aiPanelControllerModule, /export function applyAiOptionState\(type, els\)/);
assert.match(aiPanelControllerModule, /window\._buildModelOptions\(sel\)/);
assert.match(aiPanelControllerModule, /getJson\('\/api\/auth\/me'\)/);
assert.match(aiPanelControllerModule, /deps\.browseWebdav\(deps\.getWebdavPath\(\)\)/);
assert.doesNotMatch(source, /function openAiPanel\(\)|function updateAiOptions\(|function switchAiTab\(/);
assert.doesNotMatch(aiPanelControllerModule, /window\.Tiptap|ai-generate|FormData|generate-pptx/);
});
test('Learning Hub viewer renderers are isolated from API and slide loading', () => {
assert.match(source, /from '\.\/learningHub\/viewerRenderer\.js'/);
assert.match(viewerRendererModule, /export function buildViewerMeta\(item\)/);

View file

@ -36,7 +36,9 @@ test('top bar model selector is removed and omitted selections use backend defau
test('learning hub model selector uses shared tab model options', () => {
const learningHub = read('public/js/learningHub.js');
assert.match(learningHub, /window\._buildModelOptions\(sel\)/);
const aiPanelController = read('public/js/learningHub/aiPanelController.js');
assert.match(learningHub, /createAiPanelController\(/);
assert.match(aiPanelController, /window\._buildModelOptions\(sel\)/);
assert.doesNotMatch(learningHub, /global-model-select|globalSel\.innerHTML/);
assert.match(learningHub, /if \(model\) formData\.append\('model', model\)/);
});