/* learn.jsx v2 — YouTube embeds, inline term highlighting, more terms */ // Feature flag — AI tutor (Claude) is disabled in self-hosted builds. // To enable as a Pro/paid feature, see docs/AI_TUTOR_PAID_FEATURE.md const AI_TUTOR_ENABLED = false; // ===== INLINE TERM HIGHLIGHTER (also wraps user notes as ) ===== function highlightTerms(text, termsMap, onTerm, usedTerms, notes, onNoteClick) { if (!text) return text; const terms = Object.keys(termsMap||{}) .filter(t => !usedTerms.has(t.toLowerCase())) .sort((a,b) => b.length - a.length); if (!terms.length && !(notes && notes.length)) return text; const parts = []; let remaining = text; let key = 0; const usedHere = new Set(); while (remaining.length > 0) { let bestIdx = -1, bestLen = 0, bestKind = null, bestPayload = null; const lower = remaining.toLowerCase(); for (const t of terms) { if (usedHere.has(t.toLowerCase())) continue; const idx = lower.indexOf(t.toLowerCase()); if (idx !== -1 && (bestIdx === -1 || idx < bestIdx || (idx === bestIdx && t.length > bestLen))) { bestIdx = idx; bestLen = t.length; bestKind = 'term'; bestPayload = t; } } if (notes && notes.length) { for (const n of notes) { const idx = lower.indexOf(n.selectedText.toLowerCase()); if (idx !== -1 && (bestIdx === -1 || idx < bestIdx || (idx === bestIdx && n.selectedText.length > bestLen))) { bestIdx = idx; bestLen = n.selectedText.length; bestKind = 'note'; bestPayload = n; } } } if (bestIdx === -1) { parts.push(remaining); break; } if (bestIdx > 0) parts.push(remaining.slice(0, bestIdx)); const matched = remaining.slice(bestIdx, bestIdx + bestLen); if (bestKind === 'term') { usedHere.add(bestPayload.toLowerCase()); usedTerms.add(bestPayload.toLowerCase()); parts.push(); } else { const n = bestPayload; parts.push({e.stopPropagation();onNoteClick&&onNoteClick(n,e);}}>{matched}); } remaining = remaining.slice(bestIdx + bestLen); } return <>{parts}; } // ===== NOTES INFRASTRUCTURE ===== const NOTES_KEY='ccna_notes_v1'; function readNotes(){try{return JSON.parse(localStorage.getItem(NOTES_KEY))||[];}catch{return[];}} function writeNotes(n){localStorage.setItem(NOTES_KEY,JSON.stringify(n));window.dispatchEvent(new Event('ccna-notes-change'));} function useNotes(){ const[notes,setNotes]=React.useState(readNotes); React.useEffect(()=>{ const h=()=>setNotes(readNotes()); window.addEventListener('ccna-notes-change',h); return()=>window.removeEventListener('ccna-notes-change',h); },[]); return notes; } function addNote(topicId,topicTitle,selectedText,noteText){ const all=readNotes(); all.push({id:Date.now()+'_'+Math.random().toString(36).slice(2,8),topicId,topicTitle,selectedText,note:noteText,createdAt:new Date().toISOString()}); writeNotes(all); } function updateNote(id,noteText){ const all=readNotes(); const i=all.findIndex(n=>n.id===id); if(i<0)return; all[i].note=noteText; all[i].updatedAt=new Date().toISOString(); writeNotes(all); } function deleteNote(id){writeNotes(readNotes().filter(n=>n.id!==id));} Object.assign(window,{useNotes,addNote,updateNote,deleteNote}); // ===== READ-TRACKING ===== const READ_KEY_PREFIX='ccna_read_'; function readReadTopics(examId){try{return JSON.parse(localStorage.getItem(READ_KEY_PREFIX+examId))||[];}catch{return [];}} function writeReadTopics(examId,list){localStorage.setItem(READ_KEY_PREFIX+examId,JSON.stringify(list));window.dispatchEvent(new Event('ccna-read-change'));} function useReadTopics(examId){ const[list,setList]=React.useState(()=>readReadTopics(examId)); React.useEffect(()=>{setList(readReadTopics(examId));const h=()=>setList(readReadTopics(examId));window.addEventListener('ccna-read-change',h);return()=>window.removeEventListener('ccna-read-change',h);},[examId]); return list; } function markRead(examId,topicId){const cur=readReadTopics(examId);if(!cur.includes(topicId))writeReadTopics(examId,[...cur,topicId]);} function unmarkRead(examId,topicId){writeReadTopics(examId,readReadTopics(examId).filter(id=>id!==topicId));} // ===== SELECTION BUBBLE + NOTE POPOVERS ===== function useSelectionBubble(containerSel){ const[bubble,setBubble]=React.useState(null); React.useEffect(()=>{ let t; const onMU=()=>{ clearTimeout(t); t=setTimeout(()=>{ const sel=window.getSelection(); if(!sel||sel.isCollapsed){setBubble(null);return;} const text=sel.toString().trim(); if(!text||text.length<3||text.length>500){setBubble(null);return;} const range=sel.getRangeAt(0); const container=document.querySelector(containerSel); if(!container||!container.contains(range.commonAncestorContainer)){setBubble(null);return;} const r=range.getBoundingClientRect(); setBubble({text,x:r.left+r.width/2,y:r.bottom+6}); },100); }; document.addEventListener('mouseup',onMU); return()=>{document.removeEventListener('mouseup',onMU);clearTimeout(t);}; },[containerSel]); return[bubble,()=>{setBubble(null);window.getSelection&&window.getSelection().removeAllRanges();}]; } function SelectionBubble({bubble,onAdd}){ if(!bubble)return null; return(
e.preventDefault()} onClick={onAdd}>✎ Add note
); } function NoteInput({selectedText,initialText,onSave,onCancel,title}){ const[text,setText]=React.useState(initialText||''); const ref=React.useRef(null); React.useEffect(()=>{ref.current?.focus();},[]); return(
e.stopPropagation()} style={{maxWidth:'560px'}}>
{title||'Add Note'}
Highlighted text:
“{selectedText}”