Move the course advisor into a floating panel and link courses in its prose
The advisor was a card inside the home page, so reaching it meant navigating
back to #/ and it was rebuilt on every render of that view. It is now a bubble
in the bottom-right corner of every screen bar the player and a quiz, opening
a chat panel that stays put as you move around — ask what to take next, follow
a recommendation, keep asking. It is built once into #overlay-root, outside
the view that gets replaced, and syncAdvisor() called from renderChrome() is
the single path that decides whether it is on screen; that covers every route
change and every loadMeta(), and makes the Settings toggle work with no change
to its handler.
Being permanent removes the reason for the routeSeq guards in ask(). An answer
that arrived after the user navigated was written to sessionStorage and then
dropped unrendered; now it lands, and the bubble carries an unread dot if the
panel is closed. showAuth() wipes the transcript and hides the dock, both of
which are new requirements: the dock is a sibling of #app-shell, so hiding the
shell no longer hides the advisor, and the widget now outlives a sign-out.
Courses named in the answer itself are links. advisorResolveCourses() is
lifted out of advisorValidateRecommendations() so an id written inline goes
through the same existence check and the same redirect to whichever duplicate
copy the learner has progress on — a sentence and the card under it can never
point at different copies. The map is keyed by the id as the model wrote it,
since that is what the browser has to find in the text. The client splits the
raw line before escaping rather than linkifying escaped output: esc() writes
an apostrophe as ', and the course-id pattern matches the #39 inside it.
Under 900px the panel is a full-height sheet, and the mobile toast strip lifts
clear of the bubble only while the bubble is actually on screen. The z-index
sits on the dock rather than #overlay-root, which would otherwise become a
stacking context and trap the confirm dialog behind the panel.
README's advisor section is brought back in line, including the parts left
stale by 05f0abc about sections and the whole library going out per question.
Claude-Session: https://claude.ai/code/session_01LPZRrwVj6L9ckoRtK1yHAN
This commit is contained in:
+272
-100
@@ -57,6 +57,7 @@
|
||||
close: '<path d="M18 6 6 18M6 6l12 12"></path>',
|
||||
play: '<polygon points="6 4 20 12 6 20 6 4"></polygon>',
|
||||
list_ol: '<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"></path>',
|
||||
chat: '<path d="M20.5 11.6a7.9 7.9 0 0 1-8.5 7.9 9 9 0 0 1-3.1-.6L3.5 20.5l1.7-4.4a7.7 7.7 0 0 1-1.7-4.5 7.9 7.9 0 0 1 8.5-7.9 7.9 7.9 0 0 1 8.5 7.9Z"></path>',
|
||||
};
|
||||
|
||||
// Sizes are the design's own; GLYPH_SCALE keeps glyphs in step with the type
|
||||
@@ -317,6 +318,8 @@
|
||||
const avatar = document.getElementById('btn-account');
|
||||
avatar.textContent = (state.user || '?').slice(0, 2).toUpperCase();
|
||||
avatar.style.background = hueOf((state.user || '').length + 4);
|
||||
|
||||
syncAdvisor();
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
@@ -484,6 +487,11 @@
|
||||
if (cleanupView) { cleanupView(); cleanupView = null; }
|
||||
shell.hidden = true;
|
||||
authEl.hidden = false;
|
||||
// The advisor dock is a sibling of #app-shell, so hiding the shell does not
|
||||
// hide it. Clearing the transcript here is also what stops one person's
|
||||
// conversation surviving into the next sign-in in the same tab.
|
||||
advisorReset({ wipe: true });
|
||||
syncAdvisor();
|
||||
renderLogin();
|
||||
markBooted();
|
||||
}
|
||||
@@ -643,7 +651,6 @@
|
||||
<div class="eyebrow">${esc(todayLine())}</div>
|
||||
<h1 style="margin:10px 0 24px">Pick up where you stopped.</h1>
|
||||
${heroBlock(data.resume)}
|
||||
${advisorBlock()}
|
||||
${statCards(data.stats)}
|
||||
<div class="section-head">
|
||||
<h2>In progress</h2>
|
||||
@@ -652,18 +659,34 @@
|
||||
${progressBlock}
|
||||
${recentBlock}
|
||||
</div>`;
|
||||
initAdvisor();
|
||||
}
|
||||
|
||||
/* ---------- course advisor ---------- */
|
||||
|
||||
const ADVISOR_KEY = 'coursehub.advisor';
|
||||
// The transcript lives in this tab: it survives navigating around the app and
|
||||
// dies with the tab. Deliberately not 'coursehub.advisor' — that string is the
|
||||
// localStorage show/hide flag behind pref('advisor'), and the two used to sit
|
||||
// one store apart under the same name.
|
||||
const ADVISOR_KEY = 'coursehub.advisor.chat';
|
||||
|
||||
// Shown only when the server has it switched on and this browser has not hidden it.
|
||||
const advisorOn = () => !!(state.meta && state.meta.advisor) && pref('advisor') !== false;
|
||||
|
||||
// The conversation lives in this tab: it survives navigating around the app
|
||||
// and dies with the tab. It is replayed to the server on every question.
|
||||
function advisorTurns() {
|
||||
// The player and a quiz are the two screens you are *inside* something, so the
|
||||
// bubble stays out of them. It also keeps the panel's textarea clear of Plyr's
|
||||
// window-level key handler (initPlayer, keyboard.global) — belt and braces,
|
||||
// since Plyr already ignores keys typed into a textarea.
|
||||
const isFocusRoute = () => /^#\/(?:watch|quiz)\/\d+$/.test(location.hash || '#/');
|
||||
|
||||
// Built once, bound once, never torn down: the dock outlives every route, so
|
||||
// unlike a view nothing here may assume it is rebuilt on navigation.
|
||||
let advisorEl = null;
|
||||
let advisorTurns = [];
|
||||
let advisorOpen = false;
|
||||
let advisorBusy = false;
|
||||
let advisorLastFocus = null;
|
||||
|
||||
function advisorLoad() {
|
||||
try {
|
||||
const t = JSON.parse(sessionStorage.getItem(ADVISOR_KEY) || '[]');
|
||||
return Array.isArray(t) ? t : [];
|
||||
@@ -671,24 +694,47 @@
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function advisorSave(turns) {
|
||||
try { sessionStorage.setItem(ADVISOR_KEY, JSON.stringify(turns)); } catch (e) { /* not persisted */ }
|
||||
function advisorSave() {
|
||||
try { sessionStorage.setItem(ADVISOR_KEY, JSON.stringify(advisorTurns)); } catch (e) { /* not persisted */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of the model's text, with "#412" turned into a link to the course
|
||||
* the server resolved it to. The split runs over the *raw* line, never the
|
||||
* escaped one: esc() writes an apostrophe as "'", which this pattern would
|
||||
* otherwise match as a course id and cut the entity in half.
|
||||
*/
|
||||
function advisorLine(raw, courses) {
|
||||
const out = [];
|
||||
const re = /#(\d{1,9})\b/g;
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
const c = courses && courses[m[1]];
|
||||
if (!c) continue; // an id the server could not place stays plain text
|
||||
out.push(esc(raw.slice(last, m.index)));
|
||||
out.push(`<a href="#/folder/${+c.folder_id}">${esc(c.name)}</a>`);
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
out.push(esc(raw.slice(last)));
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/** The model's plain text as HTML: paragraphs, and "- " lines as bullets. Escaped first. */
|
||||
function advisorAnswerHtml(text) {
|
||||
function advisorAnswerHtml(text, courses) {
|
||||
const out = [];
|
||||
let para = [];
|
||||
let list = [];
|
||||
const flushP = () => { if (para.length) { out.push(`<p>${para.map(esc).join('<br>')}</p>`); para = []; } };
|
||||
const flushL = () => { if (list.length) { out.push(`<ul>${list.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>`); list = []; } };
|
||||
const line = (l) => advisorLine(l, courses);
|
||||
const flushP = () => { if (para.length) { out.push(`<p>${para.map(line).join('<br>')}</p>`); para = []; } };
|
||||
const flushL = () => { if (list.length) { out.push(`<ul>${list.map((l) => `<li>${line(l)}</li>`).join('')}</ul>`); list = []; } };
|
||||
for (const raw of String(text || '').split('\n')) {
|
||||
const line = raw.trim();
|
||||
const m = line.match(/^[-•*]\s+(.*)$/);
|
||||
if (!line) { flushP(); flushL(); continue; }
|
||||
const l = raw.trim();
|
||||
const m = l.match(/^[-•*]\s+(.*)$/);
|
||||
if (!l) { flushP(); flushL(); continue; }
|
||||
if (m) { flushP(); list.push(m[1]); continue; }
|
||||
flushL();
|
||||
para.push(line);
|
||||
para.push(l);
|
||||
}
|
||||
flushP();
|
||||
flushL();
|
||||
@@ -731,108 +777,233 @@
|
||||
const missing = (t.missing || []).map((m) => `<span class="pill pill-quiet">${esc(m)}</span>`).join('');
|
||||
|
||||
return `<div class="advisor-msg is-bot">
|
||||
<div class="advisor-answer">${advisorAnswerHtml(t.answer)}</div>
|
||||
<div class="advisor-answer">${advisorAnswerHtml(t.answer, t.courses)}</div>
|
||||
${recs ? `<div class="advisor-label">Suggested order</div><div class="advisor-list">${recs}</div>` : ''}
|
||||
${ine ? `<div class="advisor-label">Worth downloading</div><div class="advisor-list">${ine}</div>` : ''}
|
||||
${missing ? `<div class="advisor-label">Not covered anywhere</div><div class="advisor-prereqs">${missing}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function advisorBlock() {
|
||||
if (!advisorOn()) return '';
|
||||
return `<section class="card advisor" id="advisor">
|
||||
<div class="advisor-head">
|
||||
<h2>Course advisor</h2>
|
||||
<button class="btn-quiet" type="button" data-advisor-new>New conversation</button>
|
||||
</div>
|
||||
<p class="advisor-sub">Ask which course to take next, what to study first, or in what order — answered from your own library.</p>
|
||||
<div class="advisor-thread" id="advisor-thread"></div>
|
||||
<form class="advisor-form" id="advisor-form">
|
||||
<textarea id="advisor-input" rows="1" placeholder="I want to learn SD-WAN — where should I start?"
|
||||
aria-label="Ask the course advisor"></textarea>
|
||||
<button class="btn btn-accent" type="submit">Ask</button>
|
||||
</form>
|
||||
<div class="advisor-note" id="advisor-note"></div>
|
||||
</section>`;
|
||||
function advisorDraw(extra = '') {
|
||||
if (!advisorEl) return;
|
||||
advisorEl.root.classList.toggle('has-turns', advisorTurns.length > 0);
|
||||
advisorEl.thread.innerHTML = advisorTurns.map(advisorTurnHtml).join('') + extra;
|
||||
// The thread is its own scroll box. Pin it twice: the first run lands before
|
||||
// the web font has settled the final line heights, which leaves it short.
|
||||
const pin = () => { advisorEl.thread.scrollTop = advisorEl.thread.scrollHeight; };
|
||||
pin();
|
||||
requestAnimationFrame(pin);
|
||||
}
|
||||
|
||||
function initAdvisor() {
|
||||
const section = document.getElementById('advisor');
|
||||
if (!section) return;
|
||||
const thread = section.querySelector('#advisor-thread');
|
||||
const form = section.querySelector('#advisor-form');
|
||||
const input = section.querySelector('#advisor-input');
|
||||
const note = section.querySelector('#advisor-note');
|
||||
const submit = form.querySelector('button[type=submit]');
|
||||
let turns = advisorTurns();
|
||||
let busy = false;
|
||||
function advisorSetBusy(b) {
|
||||
advisorBusy = b;
|
||||
if (!advisorEl) return;
|
||||
advisorEl.input.disabled = b;
|
||||
advisorEl.submit.disabled = b;
|
||||
}
|
||||
|
||||
const draw = (extra = '') => { thread.innerHTML = turns.map(advisorTurnHtml).join('') + extra; };
|
||||
const setBusy = (b) => { busy = b; input.disabled = b; submit.disabled = b; };
|
||||
draw();
|
||||
/** Announces to a screen reader. The thread itself cannot be a live region:
|
||||
* advisorDraw() replaces it wholesale, so every repaint would re-read it all. */
|
||||
function advisorSay(message) {
|
||||
if (advisorEl) advisorEl.live.textContent = message;
|
||||
}
|
||||
|
||||
// What the server replays to the model: the assistant's own JSON, so the
|
||||
// follow-up sees exactly what it said before.
|
||||
const messagesFor = (list) => list.map((t) => t.role === 'user'
|
||||
? { role: 'user', content: t.text }
|
||||
: { role: 'assistant', content: JSON.stringify({
|
||||
answer: t.answer,
|
||||
recommended: (t.recommended || []).map((r) => ({ folder_id: r.folder_id, order: r.order, reason: r.reason })),
|
||||
from_ine: (t.from_ine || []).map((r) => ({ catalog_id: String(r.catalog_id).slice(0, 8), reason: r.reason })),
|
||||
missing_prerequisites: t.missing || [],
|
||||
}) });
|
||||
function advisorMarkUnread() {
|
||||
if (!advisorEl) return;
|
||||
advisorEl.dot.hidden = false;
|
||||
advisorEl.fab.setAttribute('aria-label', 'Course advisor — new answer');
|
||||
}
|
||||
|
||||
async function ask(text) {
|
||||
const seq = routeSeq;
|
||||
turns.push({ role: 'user', text });
|
||||
advisorSave(turns);
|
||||
setBusy(true);
|
||||
draw(`<div class="advisor-msg is-bot advisor-pending"><span class="spinner"></span>Thinking…</div>`);
|
||||
try {
|
||||
const res = await api('api/advisor.php', { json: { messages: messagesFor(turns) } });
|
||||
turns.push({ role: 'assistant', answer: res.answer, recommended: res.recommended || [],
|
||||
from_ine: res.from_ine || [], missing: res.missing_prerequisites || [] });
|
||||
advisorSave(turns);
|
||||
if (seq !== routeSeq || !thread.isConnected) return; // the user moved on meanwhile
|
||||
draw();
|
||||
const left = res.usage && res.usage.remaining_today;
|
||||
note.textContent = left === undefined ? '' : `${left} question${left === 1 ? '' : 's'} left today.`;
|
||||
if (res.degraded) toast('The advisor answered in plain text this time, without course links.', 'error');
|
||||
} catch (err) {
|
||||
turns.pop(); // the question goes back in the box
|
||||
advisorSave(turns);
|
||||
if (seq !== routeSeq || !thread.isConnected) return;
|
||||
if (err.status === 503) { // switched off on the server since the page loaded
|
||||
if (state.meta) state.meta.advisor = false;
|
||||
section.remove();
|
||||
toast('The course advisor is switched off.', 'error');
|
||||
return;
|
||||
}
|
||||
input.value = text;
|
||||
draw(`<div class="advisor-msg is-bot"><div class="alert">${esc(err.message)}</div></div>`);
|
||||
} finally {
|
||||
if (thread.isConnected) setBusy(false);
|
||||
/** wipe: the session ended, so drop the transcript rather than just the view of it. */
|
||||
function advisorReset({ wipe = false } = {}) {
|
||||
if (advisorBusy && !wipe) return;
|
||||
advisorTurns = [];
|
||||
if (wipe) { try { sessionStorage.removeItem(ADVISOR_KEY); } catch (e) { /* nothing to clear */ } }
|
||||
else advisorSave();
|
||||
if (!advisorEl) return;
|
||||
advisorEl.note.textContent = '';
|
||||
advisorEl.dot.hidden = true;
|
||||
advisorEl.fab.setAttribute('aria-label', 'Course advisor');
|
||||
advisorDraw();
|
||||
}
|
||||
|
||||
// What the server replays to the model: the assistant's own JSON, so the
|
||||
// follow-up sees exactly what it said before. The "#412" markers in `answer`
|
||||
// must survive — they keep a follow-up coherent, and advisorRelevant() greps
|
||||
// for them to pull those courses into the details block. `courses` is derived
|
||||
// from the answer and deliberately not replayed.
|
||||
const advisorMessages = (list) => list.map((t) => t.role === 'user'
|
||||
? { role: 'user', content: t.text }
|
||||
: { role: 'assistant', content: JSON.stringify({
|
||||
answer: t.answer,
|
||||
recommended: (t.recommended || []).map((r) => ({ folder_id: r.folder_id, order: r.order, reason: r.reason })),
|
||||
from_ine: (t.from_ine || []).map((r) => ({ catalog_id: String(r.catalog_id).slice(0, 8), reason: r.reason })),
|
||||
missing_prerequisites: t.missing || [],
|
||||
}) });
|
||||
|
||||
async function advisorAsk(text) {
|
||||
// No routeSeq guard: the panel outlives every route, so an answer that lands
|
||||
// after the user navigated is a good answer, not a stale one.
|
||||
advisorTurns.push({ role: 'user', text });
|
||||
advisorSave();
|
||||
advisorSetBusy(true);
|
||||
advisorSay('Thinking…');
|
||||
advisorDraw(`<div class="advisor-msg is-bot advisor-pending"><span class="spinner"></span>Thinking…</div>`);
|
||||
try {
|
||||
const res = await api('api/advisor.php', { json: { messages: advisorMessages(advisorTurns) } });
|
||||
advisorTurns.push({ role: 'assistant', answer: res.answer, recommended: res.recommended || [],
|
||||
from_ine: res.from_ine || [], missing: res.missing_prerequisites || [],
|
||||
courses: res.courses || {} });
|
||||
advisorSave();
|
||||
advisorDraw();
|
||||
const left = res.usage && res.usage.remaining_today;
|
||||
advisorEl.note.textContent = left === undefined ? '' : `${left} question${left === 1 ? '' : 's'} left today.`;
|
||||
advisorSay('The advisor answered.');
|
||||
if (!advisorOpen) advisorMarkUnread(); // it arrived behind a closed bubble
|
||||
if (res.degraded) toast('The advisor answered in plain text this time, without course cards.', 'error');
|
||||
} catch (err) {
|
||||
// A 401 sent us to the sign-in screen, and showAuth() has already wiped the
|
||||
// transcript — advisorTurns is a fresh array now and must not be popped.
|
||||
if (!state.user) return;
|
||||
advisorTurns.pop(); // the question goes back in the box
|
||||
advisorSave();
|
||||
if (err.status === 503) { // switched off on the server since the page loaded
|
||||
if (state.meta) state.meta.advisor = false;
|
||||
closeAdvisor({ restoreFocus: false });
|
||||
syncAdvisor();
|
||||
toast('The course advisor is switched off.', 'error');
|
||||
return;
|
||||
}
|
||||
advisorEl.input.value = text;
|
||||
advisorDraw(`<div class="advisor-msg is-bot"><div class="alert">${esc(err.message)}</div></div>`);
|
||||
} finally {
|
||||
advisorSetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const advisorIsSheet = () => matchMedia('(max-width: 900px)').matches;
|
||||
|
||||
function openAdvisor() {
|
||||
if (!advisorEl || advisorOpen) return;
|
||||
advisorOpen = true;
|
||||
advisorLastFocus = document.activeElement;
|
||||
advisorEl.root.classList.add('is-open');
|
||||
advisorEl.panel.hidden = false;
|
||||
advisorEl.scrim.hidden = false;
|
||||
advisorEl.fab.setAttribute('aria-expanded', 'true');
|
||||
advisorEl.dot.hidden = true;
|
||||
advisorEl.fab.setAttribute('aria-label', 'Course advisor');
|
||||
document.body.classList.toggle('advisor-sheet-open', advisorIsSheet());
|
||||
advisorDraw();
|
||||
advisorEl.input.focus();
|
||||
}
|
||||
|
||||
function closeAdvisor({ restoreFocus = true } = {}) {
|
||||
if (!advisorEl || !advisorOpen) return;
|
||||
advisorOpen = false;
|
||||
advisorEl.root.classList.remove('is-open');
|
||||
advisorEl.panel.hidden = true;
|
||||
advisorEl.scrim.hidden = true;
|
||||
advisorEl.fab.setAttribute('aria-expanded', 'false');
|
||||
document.body.classList.remove('advisor-sheet-open');
|
||||
if (!restoreFocus) return; // the route changed; the old focus is gone with it
|
||||
const back = advisorLastFocus && advisorLastFocus.isConnected ? advisorLastFocus : advisorEl.fab;
|
||||
back.focus();
|
||||
}
|
||||
|
||||
/** Builds the dock on first use, so a browser with the advisor hidden never
|
||||
* gets the markup or the listeners. Idempotent. */
|
||||
function renderAdvisor() {
|
||||
if (advisorEl) return;
|
||||
const root = document.createElement('div');
|
||||
root.className = 'advisor-dock';
|
||||
root.hidden = true;
|
||||
// The bubble comes first so Tab runs bubble → panel; CSS stacks the panel
|
||||
// above it with column-reverse.
|
||||
root.innerHTML = `
|
||||
<div class="advisor-scrim" data-advisor-close hidden></div>
|
||||
<button class="advisor-fab" type="button" aria-expanded="false" aria-controls="advisor-panel"
|
||||
aria-label="Course advisor" title="Course advisor">
|
||||
${icon('chat', 22, 1.7)}<span class="advisor-dot" hidden></span>
|
||||
</button>
|
||||
<div class="advisor-panel" id="advisor-panel" role="dialog" aria-labelledby="advisor-title" hidden>
|
||||
<div class="advisor-head">
|
||||
<h2 id="advisor-title">Course advisor</h2>
|
||||
<button class="btn-quiet" type="button" data-advisor-new>New</button>
|
||||
<button class="icon-btn" type="button" data-advisor-close
|
||||
aria-label="Close the course advisor">${icon('close', 16, 1.8)}</button>
|
||||
</div>
|
||||
<p class="advisor-sub">Ask what to take next, or in what order — answered from your own library.</p>
|
||||
<div class="advisor-thread"></div>
|
||||
<form class="advisor-form">
|
||||
<textarea rows="1" placeholder="What should I learn next?"
|
||||
aria-label="Ask the course advisor"></textarea>
|
||||
<button class="btn btn-accent" type="submit">Ask</button>
|
||||
</form>
|
||||
<div class="advisor-note"></div>
|
||||
<span class="sr-only" role="status" aria-live="polite"></span>
|
||||
</div>`;
|
||||
document.getElementById('overlay-root').appendChild(root);
|
||||
|
||||
const form = root.querySelector('.advisor-form');
|
||||
advisorEl = {
|
||||
root,
|
||||
fab: root.querySelector('.advisor-fab'),
|
||||
dot: root.querySelector('.advisor-dot'),
|
||||
scrim: root.querySelector('.advisor-scrim'),
|
||||
panel: root.querySelector('.advisor-panel'),
|
||||
thread: root.querySelector('.advisor-thread'),
|
||||
input: form.querySelector('textarea'),
|
||||
submit: form.querySelector('button[type=submit]'),
|
||||
note: root.querySelector('.advisor-note'),
|
||||
live: root.querySelector('.sr-only'),
|
||||
};
|
||||
|
||||
advisorTurns = advisorLoad();
|
||||
advisorDraw();
|
||||
|
||||
advisorEl.fab.addEventListener('click', () => (advisorOpen ? closeAdvisor() : openAdvisor()));
|
||||
root.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-advisor-close]')) closeAdvisor();
|
||||
});
|
||||
root.querySelector('[data-advisor-new]').addEventListener('click', () => {
|
||||
advisorReset();
|
||||
advisorEl.input.focus();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text || busy) return;
|
||||
input.value = '';
|
||||
ask(text);
|
||||
const text = advisorEl.input.value.trim();
|
||||
if (!text || advisorBusy) return;
|
||||
advisorEl.input.value = '';
|
||||
advisorAsk(text);
|
||||
});
|
||||
input.addEventListener('keydown', (e) => { // Enter asks, Shift+Enter breaks the line
|
||||
advisorEl.input.addEventListener('keydown', (e) => { // Enter asks, Shift+Enter breaks the line
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
|
||||
});
|
||||
section.querySelector('[data-advisor-new]').addEventListener('click', () => {
|
||||
if (busy) return;
|
||||
turns = [];
|
||||
advisorSave(turns);
|
||||
note.textContent = '';
|
||||
draw();
|
||||
input.focus();
|
||||
advisorEl.thread.addEventListener('click', (e) => {
|
||||
// The desktop panel sits beside the app, so a course link just navigates
|
||||
// underneath it. The mobile sheet covers the page it navigated to.
|
||||
if (e.target.closest('a[href^="#/"]') && advisorIsSheet()) closeAdvisor({ restoreFocus: false });
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape' || !advisorOpen) return;
|
||||
if (document.querySelector('.overlay, .menu')) return; // a dialog or the account menu owns this one
|
||||
closeAdvisor();
|
||||
});
|
||||
}
|
||||
|
||||
/** The one path that decides whether the bubble is on screen. Called from
|
||||
* renderChrome(), so every route change and every loadMeta() covers it. */
|
||||
function syncAdvisor() {
|
||||
const want = !!state.user && advisorOn() && !isFocusRoute();
|
||||
document.body.classList.toggle('has-advisor', want); // shifts the mobile toast strip clear of it
|
||||
if (!want) {
|
||||
if (advisorEl) { closeAdvisor({ restoreFocus: false }); advisorEl.root.hidden = true; }
|
||||
return;
|
||||
}
|
||||
renderAdvisor();
|
||||
advisorEl.root.hidden = false;
|
||||
}
|
||||
|
||||
/* ---------- library ---------- */
|
||||
@@ -1396,11 +1567,12 @@
|
||||
You have used ${data.advisor.used_today} of ${data.advisor.limit} question${data.advisor.limit === 1 ? '' : 's'} today.</p>
|
||||
<div class="toggle-row">
|
||||
<div style="flex:1">
|
||||
<div class="label">Show the advisor on the home page</div>
|
||||
<div class="sub">Hides the box in this browser only; the server setting stays on.</div>
|
||||
<div class="label">Show the advisor button</div>
|
||||
<div class="sub">The bubble in the bottom-right corner, on every screen but the player
|
||||
and a quiz. This browser only; the server setting stays on.</div>
|
||||
</div>
|
||||
<button class="switch${pref('advisor') !== false ? ' is-on' : ''}" role="switch"
|
||||
aria-checked="${pref('advisor') !== false}" aria-label="Show the advisor on the home page"
|
||||
aria-checked="${pref('advisor') !== false}" aria-label="Show the advisor button"
|
||||
data-toggle="advisor"><span></span></button>
|
||||
</div>`
|
||||
: `<p>Off. Set AI_ENABLED and AI_API_KEY in config.php to turn it on — the app works the same without it,
|
||||
|
||||
Reference in New Issue
Block a user