1/** 2 * online/js/ui/course-selector.js 3 * 4 * Course management UI: list, switching, creation, rename, delete, import/export. 5 * 6 * This module renders the course overlay and hosted quick-switcher. 7 * Relies on injected callbacks for auth/sync side effects. 8 * 9 * Source: 10 * online/index.html lines 9739â9850 (renderCourseQuickSwitch, getCourseQuickSwitchLabel) 11 * online/index.html lines 9851â9927 (showCourseSelector, selectCourse, closeCourseSelector) 12 * online/index.html lines 9927â10442 (selector actions + hosted helper tree) 13 */ 14 15import { 16 CourseManager, 17 ONLINE_ONLY_MODE, 18 onlineOnlyState, 19 writeCourseExportData, 20 setCourseSyncMeta, 21} from '../services/storage-service.js'; 22import { closeModal } from './modals.js'; 23import { showAlert, showConfirm } from './modal.js'; 24import { 25 getStoredCloudSession, 26 getCurrentCloudProfile, 27 isCloudAccountApproved, 28 saveCloudSession, 29 redirectHostedAuthToLogin, 30} from '../../../shared/cloud-auth.js'; 31import { 32 GLOBAL_KEYS, 33 ONLINE_CONNECTION_WARNING_DELAY_MS, 34 ROUTES, 35} from '../../../shared/constants.js'; 36import { supabaseAuthRequest } from '../../../shared/supabase.js'; 37import { 38 getRemoteCoursePresentationMeta, 39 resolveRemoteCoursePayload, 40} from '../services/sync-service.js'; 41 42const courseSelectorState = { 43 mode: 'local', 44 loading: false, 45 error: '', 46 remoteCourses: [], 47 formEmail: '', 48 formPassword: '', 49 sharedDevice: false, 50}; 51 52const deps = { 53 getConfig: () => ({}), 54 sync: null, 55 ensureHostedWorkspaceReady: async () => true, 56 fetchCloudProfile: async () => null, 57 renderCloudSyncPanel: () => {}, 58 handleCloudSignOut: async () => {}, 59 stageWorkspaceKeyHandoffForReload: async () => {}, 60}; 61 62function escapeHtml(str) { 63 return String(str || '') 64 .replace(/&/g, '&') 65 .replace(/</g, '<') 66 .replace(/>/g, '>') 67 .replace(/"/g, '"'); 68} 69 70function escapeAttr(str) { 71 return escapeHtml(str); 72} 73 74function toTimestamp(value, fallback = Date.now()) { 75 const numeric = Number(value); 76 if (Number.isFinite(numeric)) return numeric; 77 const parsed = Date.parse(value); 78 return Number.isFinite(parsed) ? parsed : fallback; 79} 80 81function formatSyncMoment(value) { 82 const timestamp = toTimestamp(value, 0); 83 if (!timestamp) return 'Not yet'; 84 return new Date(timestamp).toLocaleString([], { 85 month: 'short', 86 day: 'numeric', 87 year: 'numeric', 88 hour: 'numeric', 89 minute: '2-digit', 90 }); 91} 92 93function getRemoteUpdatedAt(remoteRow) { 94 return toTimestamp(remoteRow?.data?.updatedAt || remoteRow?.updated_at, 0); 95} 96 97function openRequestAccessPage(prefill = {}) { 98 const target = new URL(ROUTES.requestAccess, window.location.origin); 99 const email = String(prefill.email || '').trim(); 100 if (email) target.searchParams.set('email', email); 101 window.location.assign(target.toString()); 102} 103 104function getPasswordRecoveryRedirectUrl() { 105 return new URL(ROUTES.reset, window.location.origin).toString(); 106} 107 108function isHostedWorkspaceLocked(session = getStoredCloudSession().session, profile = getCurrentCloudProfile(session)) { 109 if (!ONLINE_ONLY_MODE) return false; 110 if (!session) return true; 111 if (!profile) return false; 112 return !isCloudAccountApproved(profile); 113} 114 115function updateCourseSelectorHeader() { 116 const title = document.getElementById('courseSelectorTitle'); 117 const closeBtn = document.getElementById('courseSelectorCloseBtn'); 118 if (!title && !closeBtn) return; 119 120 const session = getStoredCloudSession().session; 121 const profile = getCurrentCloudProfile(session); 122 const locked = isHostedWorkspaceLocked(session, profile); 123 const approved = !!session && isCloudAccountApproved(profile); 124 const hasCourses = CourseManager.getAllCourses().length > 0; 125 126 if (title) { 127 title.textContent = ONLINE_ONLY_MODE && approved && !hasCourses 128 ? 'Welcome' 129 : 'Select Course'; 130 } 131 132 if (closeBtn) { 133 const canClose = !locked && !(ONLINE_ONLY_MODE && approved && !hasCourses); 134 closeBtn.style.display = canClose ? '' : 'none'; 135 closeBtn.disabled = !canClose; 136 closeBtn.title = canClose ? 'Close' : 'Create a course, import a backup, or sign out to continue.'; 137 closeBtn.setAttribute('aria-hidden', canClose ? 'false' : 'true'); 138 } 139} 140 141function isHostedConnectionWarningActive() { 142 if (!ONLINE_ONLY_MODE) return false;
143 if (onlineOnlyState.networkOnline !== false && navigator.onLine !== false) return false; 144 if (!onlineOnlyState.connectivityIssueSince) return false; 145 return (Date.now() - onlineOnlyState.connectivityIssueSince) >= ONLINE_CONNECTION_WARNING_DELAY_MS; 146} 147 148function getNavCourseConnectionTone() { 149 if (!ONLINE_ONLY_MODE) return ''; 150 const session = getStoredCloudSession().session; 151 if (!session || !isCloudAccountApproved(getCurrentCloudProfile(session))) return ''; 152 return isHostedConnectionWarningActive() ? 'warning' : 'online'; 153} 154 155export function updateNavCourseConnectionIndicator() { 156 const button = document.getElementById('displaySubTitle'); 157 if (!button) return; 158 const tone = getNavCourseConnectionTone(); 159 if (tone) { 160 button.dataset.connectionTone = tone; 161 } else { 162 delete button.dataset.connectionTone; 163 } 164} 165 166function getCourseQuickSwitchLabel() { 167 const activeCourseId = CourseManager.getActiveCourseId(); 168 if (!activeCourseId && CourseManager.getAllCourses().length === 0) return ''; 169 const config = (typeof deps.getConfig === 'function' ? deps.getConfig() : {}) || {}; 170 const activeCourse = activeCourseId ? CourseManager.getCourse(activeCourseId) : null; 171 const fallbackCourse = activeCourse || CourseManager.getAllCourses()[0] || null; 172 return ( 173 config.subTitle || 174 activeCourse?.name || 175 fallbackCourse?.name || 176 '' 177 ).trim(); 178} 179 180export function closeCourseQuickSwitch() { 181 const button = document.getElementById('displaySubTitle'); 182 const menu = document.getElementById('courseQuickSwitchMenu'); 183 if (button) { 184 button.classList.remove('is-open'); 185 button.setAttribute('aria-expanded', 'false'); 186 } 187 if (menu) menu.classList.remove('visible'); 188} 189 190export function renderCourseQuickSwitch() { 191 const wrap = document.getElementById('courseQuickSwitchWrap'); 192 const button = document.getElementById('displaySubTitle'); 193 const menu = document.getElementById('courseQuickSwitchMenu'); 194 if (!wrap || !button || !menu) return; 195 196 const courses = CourseManager.getAllCourses() 197 .slice() 198 .sort((a, b) => { 199 const aName = (a.name || 'Untitled Course').trim(); 200 const bName = (b.name || 'Untitled Course').trim(); 201 return aName.localeCompare(bName, undefined, { sensitivity: 'base', numeric: true }); 202 }); 203 const activeCourseId = CourseManager.getActiveCourseId(); 204 const label = getCourseQuickSwitchLabel(); 205 206 if (!label) { 207 const shouldKeepVisible = ONLINE_ONLY_MODE && courses.length > 0; 208 wrap.style.display = shouldKeepVisible ? 'block' : 'none'; 209 button.textContent = ''; 210 menu.innerHTML = ''; 211 delete button.dataset.connectionTone; 212 document.title = 'Class Action'; 213 if (shouldKeepVisible) { 214 button.textContent = 'Course'; 215 button.style.display = 'inline-flex'; 216 } 217 return; 218 } 219 220 wrap.style.display = 'block'; 221 button.textContent = label; 222 button.style.display = 'inline-flex'; 223 updateNavCourseConnectionIndicator(); 224 document.title = `Class Action | ${label}`; 225 226 const canQuickSwitch = courses.length > 1; 227 const hostedMenuEnabled = ONLINE_ONLY_MODE || canQuickSwitch; 228 button.classList.toggle('has-switches', hostedMenuEnabled); 229 button.title = ONLINE_ONLY_MODE ? 'Open course menu' : (canQuickSwitch ? 'Switch courses' : ''); 230 if (!hostedMenuEnabled) { 231 closeCourseQuickSwitch(); 232 menu.innerHTML = ''; 233 return; 234 } 235 236 const footerHtml = ONLINE_ONLY_MODE ? ` 237 <div class="course-quick-switch-footer"> 238 <button type="button" class="course-quick-switch-text-link" data-action="show-course-selector-from-quick-switch"> 239 All Courses 240 </button> 241 <button type="button" class="course-quick-switch-text-link" data-action="handle-course-selector-cloud-sign-out-from-quick-switch"> 242 Sign Out 243 </button> 244 </div> 245 ` : ''; 246 247 menu.innerHTML = ` 248 <div class="course-quick-switch-header utility-popover-header"> 249 <div class="utility-popover-title"> 250 <i class="fas fa-graduation-cap"></i> 251 <span>Course Switcher</span> 252 </div> 253 <div class="utility-popover-actions"> 254 <button type="button" class="utility-popover-close" data-action="close-course-quick-switch" aria-label="Close course switcher"> 255 <i class="fas fa-times"></i> 256 </button> 257 </div> 258 </div> 259 <div class="course-quick-switch-list"> 260 </div> 261 ${footerHtml} 262 `; 263 264 const listEl = menu.querySelector('.course-quick-switch-list');
265 courses.forEach((course) => { 266 const isActive = course.id === activeCourseId; 267 const lastUsed = new Date(course.lastAccessed || Date.now()).toLocaleDateString([], { 268 month: 'short', 269 day: 'numeric', 270 year: 'numeric', 271 }); 272 273 const item = document.createElement('button'); 274 item.type = 'button'; 275 item.className = `course-quick-switch-item${isActive ? ' active' : ''}`; 276 item.setAttribute('role', 'menuitem'); 277 item.addEventListener('click', () => { 278 if (isActive) { 279 closeCourseQuickSwitch(); 280 return; 281 } 282 selectCourse(course.id); 283 }); 284 285 const copy = document.createElement('span'); 286 copy.className = 'course-quick-switch-copy'; 287 288 const nameEl = document.createElement('span'); 289 nameEl.className = 'course-quick-switch-name'; 290 nameEl.textContent = course.name || 'Untitled Course'; 291 292 const metaEl = document.createElement('span'); 293 metaEl.className = 'course-quick-switch-meta'; 294 metaEl.textContent = isActive ? 'Currently open' : `Last used ${lastUsed}`; 295 296 const icon = document.createElement('i'); 297 icon.className = `course-quick-switch-icon fas ${isActive ? 'fa-check' : 'fa-arrow-right'}`; 298 299 copy.appendChild(nameEl); 300 copy.appendChild(metaEl); 301 item.appendChild(copy); 302 item.appendChild(icon); 303 listEl.appendChild(item); 304 }); 305} 306 307/** 308 * Render the mobile-only course switcher inside the hamburger slide-out menu. 309 * Shows current course and a list of other courses to switch to. 310 */ 311export function renderMobileCourseSwitch() { 312 const container = document.getElementById('mobileCourseSwitch'); 313 if (!container) return; 314 315 const courses = CourseManager.getAllCourses() 316 .slice() 317 .sort((a, b) => { 318 const aName = (a.name || 'Untitled Course').trim(); 319 const bName = (b.name || 'Untitled Course').trim(); 320 return aName.localeCompare(bName, undefined, { sensitivity: 'base', numeric: true }); 321 }); 322 const activeCourseId = CourseManager.getActiveCourseId(); 323 324 const activeLabel = getCourseQuickSwitchLabel() || 'Untitled Course'; 325 if (!activeLabel && courses.length === 0) { 326 container.innerHTML = ''; 327 container.style.display = 'none'; 328 return; 329 } 330 container.style.display = ''; 331 332 container.innerHTML = ` 333 <div class="mobile-course-switch-label"> 334 <i class="fas fa-graduation-cap"></i> 335 <span>${escapeHtml(activeLabel)}</span> 336 </div> 337 <div class="mobile-course-switch-list"></div> 338 `; 339 340 const listEl = container.querySelector('.mobile-course-switch-list'); 341 courses.forEach((course) => { 342 if (course.id === activeCourseId) return; 343 const btn = document.createElement('button'); 344 btn.type = 'button'; 345 btn.className = 'mobile-course-switch-item'; 346 btn.dataset.action = 'mobile-switch-course'; 347 btn.dataset.courseId = course.id; 348 btn.innerHTML = `<i class="fas fa-arrow-right"></i> Switch to ${escapeHtml(course.name || 'Untitled Course')}`; 349 listEl.appendChild(btn); 350 }); 351} 352 353export function showCourseSelector(options = {}) { 354 closeCourseQuickSwitch(); 355 const overlay = document.getElementById('courseSelectorOverlay'); 356 if (!overlay) return; 357 const session = getStoredCloudSession().session; 358 const profile = getCurrentCloudProfile(session); 359 if (ONLINE_ONLY_MODE && !window.__DEMO_MODE) { 360 if (!session?.user?.id) { 361 redirectHostedAuthToLogin({ email: session?.user?.email || '', forcePage: true }); 362 return; 363 } 364 if (profile && !isCloudAccountApproved(profile)) { 365 redirectHostedAuthToLogin({ email: session?.user?.email || '', forcePage: true }); 366 return; 367 } 368 } 369 courseSelectorState.mode = options.mode || 'local'; 370 if (typeof options.error === 'string') { 371 courseSelectorState.error = options.error; 372 } else { 373 courseSelectorState.error = ''; 374 } 375 renderCourseSelector(); 376 overlay.classList.add('visible'); 377 if (ONLINE_ONLY_MODE && session?.user?.id) { 378 deps.fetchCloudProfile(true).then(() => { 379 if (overlay.classList.contains('visible')) renderCourseSelector(); 380 deps.renderCloudSyncPanel(); 381 }).catch(() => {}); 382 } 383} 384 385export async function selectCourse(courseId) { 386 closeCourseQuickSwitch(); 387 if (ONLINE_ONLY_MODE && !(await deps.ensureHostedWorkspaceReady({ forceProfile: true }))) return; 388 const currentCourseId = CourseManager.getActiveCourseId(); 389 if (currentCourseId && currentCourseId === courseId) { 390 closeCourseSelector(true); 391 window.showView?.('plan'); 392 return; 393 } 394 if (ONLINE_ONLY_MODE && currentCourseId && currentCourseId !== courseId && deps.sync?.flushCloudCourseSave) { 395 try { 396 await deps.sync.flushCloudCourseSave(currentCourseId); 397 } catch (error) { 398 const proceed = await showConfirm( 399 `The current course could not finish saving before switching.\n\n` + 400 `${error.message}\n\n` + 401 `Switch courses anyway?` 402 ); 403 if (!proceed) return; 404 } 405 } 406 CourseManager.setActiveCourse(courseId); 407 closeCourseSelector(true); 408 if (ONLINE_ONLY_MODE) {
409 await deps.stageWorkspaceKeyHandoffForReload(); 410 } 411 location.reload(); 412} 413 414export function closeCourseSelector(force = false) { 415 const session = getStoredCloudSession().session; 416 const profile = getCurrentCloudProfile(session); 417 const approved = !!session && isCloudAccountApproved(profile); 418 const hasCourses = CourseManager.getAllCourses().length > 0; 419 if (!force && ONLINE_ONLY_MODE && !onlineOnlyState.initialized) return; 420 if (!force && ONLINE_ONLY_MODE && !window.__DEMO_MODE && isHostedWorkspaceLocked()) { 421 redirectHostedAuthToLogin(); 422 return; 423 } 424 if (!force && ONLINE_ONLY_MODE && approved && !hasCourses) return; 425 closeModal('courseSelectorOverlay'); 426 courseSelectorState.mode = force && isHostedWorkspaceLocked() ? 'cloud' : 'local'; 427 courseSelectorState.loading = false; 428 courseSelectorState.error = ''; 429} 430 431function renderCourseSelectorActions() { 432 const actions = document.getElementById('courseSelectorActions'); 433 if (!actions) return; 434 435 if (ONLINE_ONLY_MODE) { 436 actions.style.display = courseSelectorState.mode === 'cloud' ? 'none' : 'grid'; 437 actions.style.gridTemplateColumns = 'repeat(2, minmax(0, 1fr))'; 438 actions.innerHTML = ` 439 <button class="btn btn-primary" data-action="create-new-course" style="grid-column:1 / -1;"> 440 Create New Course 441 </button> 442 <button class="btn btn-gold" data-action="trigger-backup-input"> 443 Import Backup 444 </button> 445 <button class="btn btn-outline" data-action="handle-course-selector-cloud-sign-out"> 446 Sign Out 447 </button> 448 `; 449 return; 450 } 451 452 const cloudBtn = document.getElementById('courseSelectorCloudBtn'); 453 if (!cloudBtn) return; 454 actions.style.display = courseSelectorState.mode === 'cloud' ? 'none' : 'grid'; 455 cloudBtn.innerHTML = courseSelectorState.loading ? 'Working...' : 'Restore Online Backup'; 456 cloudBtn.disabled = !!courseSelectorState.loading; 457} 458 459export function renderCourseSelector() { 460 const container = document.getElementById('courseListContainer'); 461 if (!container) return; 462 463 updateCourseSelectorHeader(); 464 renderCourseSelectorActions(); 465 466 if (ONLINE_ONLY_MODE && !window.__DEMO_MODE) { 467 const session = getStoredCloudSession().session; 468 const profile = getCurrentCloudProfile(session); 469 if (!session?.user?.id) { 470 redirectHostedAuthToLogin({ email: session?.user?.email || '', forcePage: true }); 471 return; 472 } 473 if (profile && !isCloudAccountApproved(profile)) { 474 redirectHostedAuthToLogin({ email: session?.user?.email || '', forcePage: true }); 475 return; 476 } 477 } 478 479 if (courseSelectorState.mode === 'cloud') { 480 renderCloudCourseSelectorView(); 481 return; 482 } 483 484 const courses = CourseManager.getAllCourses(); 485 const activeCourseId = CourseManager.getActiveCourseId(); 486 const session = getStoredCloudSession().session; 487 const profile = getCurrentCloudProfile(session); 488 const errorHtml = courseSelectorState.error 489 ? `<div style="margin-bottom:14px; padding:12px 14px; border:1px solid rgba(160, 22, 18, 0.2); border-radius:10px; background:rgba(160, 22, 18, 0.08); color:var(--text-main); font-size:0.82rem; line-height:1.5;">${escapeAttr(courseSelectorState.error)}</div>` 490 : ''; 491 492 if (courses.length === 0) { 493 container.style.maxHeight = 'none'; 494 container.style.overflowY = 'visible'; 495 496 if (ONLINE_ONLY_MODE && session && isCloudAccountApproved(profile)) { 497 container.innerHTML = ` 498 ${errorHtml} 499 <div style="display:grid; gap:14px;"> 500 <div class="panel" style="margin:0; padding:24px 22px;"> 501 <h2 style="margin:0 0 14px; font-size:2rem; line-height:1.02;">Welcome to Class Action</h2> 502 <p style="margin:0 0 14px; color:var(--text-main); line-height:1.7;"> 503 Class Action brings lesson planning, attendance, groups, Socratic calling, timing, and course organization into one workspace. 504 </p> 505 <p style="margin:0; color:var(--text-muted); line-height:1.65;"> 506 Your account security is already set up. Class Action encrypts your course data before it is stored online. Create your first hosted course or import a backup to get started. 507 </p> 508 </div> 509 </div> 510 `; 511 return; 512 } 513 514 const accountHtml = ONLINE_ONLY_MODE && session && isCloudAccountApproved(profile) ? ` 515 <div style="margin-bottom:14px; padding:16px 18px; border:1px solid var(--line); border-radius:16px; background:rgba(0,0,0,0.015);"> 516 <div style="font-size:0.95rem; font-weight:700; margin-bottom:4px;">Signed in as ${escapeAttr(session.user?.email || 'Unknown user')}</div> 517 <div style="font-size:0.8rem; color:var(--text-muted); line-height:1.6;"> 518 Create a course or import a backup to start in Class Action. 519 </div> 520 </div> 521 ` : ''; 522 container.innerHTML = ` 523 ${errorHtml} 524 ${accountHtml} 525 <div style="text-align:center; padding:40px 20px; color:var(--text-muted);"> 526 <p style="font-size:1.1rem; margin:0;">${ONLINE_ONLY_MODE ? 'No online courses yet' : 'No courses yet'}</p> 527 <p style="font-size:0.85rem; margin-top:5px;">${ONLINE_ONLY_MODE ? 'Create a new online course or import a JSON backup to start in Class Action.' : 'Create a new course, restore a local file, or restore an online backup.'}</p> 528 </div> 529 `; 530 return; 531 } 532 533 container.style.maxHeight = '400px'; 534 container.style.overflowY = 'auto'; 535 courses.sort((a, b) => b.lastAccessed - a.lastAccessed); 536 container.innerHTML = errorHtml; 537 538 const list = document.createElement('div'); 539 list.style.display = 'flex'; 540 list.style.flexDirection = 'column'; 541 list.style.gap = '10px'; 542
543 courses.forEach((course) => { 544 const isActive = course.id === activeCourseId; 545 const lastAccessed = new Date(course.lastAccessed).toLocaleDateString([], { 546 month: 'short', 547 day: 'numeric', 548 year: 'numeric', 549 hour: '2-digit', 550 minute: '2-digit', 551 }); 552 553 const panel = document.createElement('div'); 554 panel.className = 'panel'; 555 panel.style.margin = '0'; 556 panel.style.padding = '15px'; 557 panel.style.cursor = 'pointer'; 558 panel.style.border = `2px solid ${isActive ? 'var(--ui-gold)' : 'var(--border)'}`; 559 panel.style.transition = 'all 0.2s'; 560 panel.addEventListener('mouseover', () => { 561 panel.style.borderColor = 'var(--ui-gold)'; 562 }); 563 panel.addEventListener('mouseout', () => { 564 panel.style.borderColor = isActive ? 'var(--ui-gold)' : 'var(--border)'; 565 }); 566 panel.addEventListener('click', () => { 567 selectCourse(course.id); 568 }); 569 570 const row = document.createElement('div'); 571 row.style.display = 'flex'; 572 row.style.justifyContent = 'space-between'; 573 row.style.alignItems = 'center'; 574 575 const copy = document.createElement('div'); 576 577 const title = document.createElement('div'); 578 title.style.fontSize = '1.1rem'; 579 title.style.fontWeight = '700'; 580 title.style.marginBottom = '5px'; 581 582 if (isActive) { 583 const icon = document.createElement('i'); 584 icon.className = 'fas fa-check-circle'; 585 icon.style.color = 'var(--ui-gold)'; 586 icon.style.marginRight = '8px'; 587 title.appendChild(icon); 588 } 589 590 title.appendChild(document.createTextNode(course.name || 'Untitled Course')); 591 592 const meta = document.createElement('div'); 593 meta.style.fontSize = '0.75rem'; 594 meta.style.color = 'var(--text-muted)'; 595 meta.textContent = `Last accessed: ${lastAccessed}`; 596 597 const arrow = document.createElement('i'); 598 arrow.className = 'fas fa-arrow-right'; 599 arrow.style.color = 'var(--text-muted)'; 600 arrow.style.fontSize = '1.2rem'; 601 602 copy.appendChild(title); 603 copy.appendChild(meta); 604 row.appendChild(copy); 605 row.appendChild(arrow); 606 panel.appendChild(row); 607 list.appendChild(panel); 608 }); 609 610 container.appendChild(list); 611} 612 613function renderCloudCourseSelectorView() { 614 const container = document.getElementById('courseListContainer'); 615 if (!container) return; 616 617 const session = getStoredCloudSession().session; 618 const profile = getCurrentCloudProfile(session); 619 if (ONLINE_ONLY_MODE && !window.__DEMO_MODE && !session?.user?.id) { 620 redirectHostedAuthToLogin({ email: session?.user?.email || '' }); 621 return; 622 } 623 if (ONLINE_ONLY_MODE && session && isCloudAccountApproved(profile)) { 624 courseSelectorState.mode = 'local'; 625 renderCourseSelector(); 626 return; 627 } 628 629 const errorHtml = courseSelectorState.error 630 ? `<div style="margin-bottom:14px; padding:12px 14px; border:1px solid rgba(160, 22, 18, 0.2); border-radius:10px; background:rgba(160, 22, 18, 0.08); color:var(--text-main); font-size:0.82rem; line-height:1.5;">${escapeAttr(courseSelectorState.error)}</div>` 631 : ''; 632 633 if (!session) { 634 container.innerHTML = ` 635 <div class="course-selector-login-stack"> 636 <div class="course-selector-login-copy"> 637 <strong>${ONLINE_ONLY_MODE ? 'Login' : 'Login to load a Cloud Backup'}</strong> 638 <p>${ONLINE_ONLY_MODE ? 'Use your Class Action account to open your courses. Need an account? Use the request-access link below.' : 'Use the same account you used on the other device. After login, you can import a saved Cloud Backup into this browser.'}</p> 639 </div> 640 ${errorHtml} 641 <div class="sync-auth-grid"> 642 <div class="sync-field"> 643 <label for="courseSelectorEmail">Email</label> 644 <input id="courseSelectorEmail" name="email" type="email" autocomplete="username" placeholder="[email protected]" value="${escapeAttr(courseSelectorState.formEmail || '')}"> 645 </div> 646 <div class="sync-field"> 647 <label for="courseSelectorPassword">Password</label> 648 <input id="courseSelectorPassword" name="password" type="password" autocomplete="current-password" data-password-toggle="true" placeholder="Password" value="${escapeAttr(courseSelectorState.formPassword || '')}"> 649 </div> 650 </div> 651 <div class="course-selector-device-row"> 652 <div class="course-selector-device-copy"> 653 <strong>Shared device</strong>
654 <span>Use session-only login on borrowed or classroom machines.</span> 655 </div> 656 <label class="settings-toggle-row compact" for="courseSelectorSharedDevice" style="margin:0;"> 657 <span class="settings-switch"> 658 <input type="checkbox" id="courseSelectorSharedDevice" class="settings-switch-input" ${courseSelectorState.sharedDevice ? 'checked' : ''}> 659 <span class="settings-switch-track"><span class="settings-switch-thumb"></span></span> 660 </span> 661 </label> 662 </div> 663 <div class="course-selector-login-actions"> 664 <button class="btn btn-primary" type="button" data-action="handle-course-selector-cloud-sign-in" ${courseSelectorState.loading ? 'disabled' : ''}> 665 ${courseSelectorState.loading ? 'Logging In...' : (ONLINE_ONLY_MODE ? 'Login' : 'Login & View Backups')} 666 </button> 667 </div> 668 <div class="course-selector-login-links"> 669 ${ONLINE_ONLY_MODE ? ` 670 <button class="course-selector-text-link" type="button" data-action="handle-course-selector-cloud-sign-up" ${courseSelectorState.loading ? 'disabled' : ''}> 671 Request access 672 </button> 673 ` : ''} 674 <button class="course-selector-text-link" type="button" data-action="handle-course-selector-password-reset" ${courseSelectorState.loading ? 'disabled' : ''}> 675 Forgot password? 676 </button> 677 </div> 678 </div> 679 `; 680 return; 681 } 682 683 if (ONLINE_ONLY_MODE && !window.__DEMO_MODE && !isCloudAccountApproved(profile)) { 684 redirectHostedAuthToLogin({ email: session.user?.email || '', forcePage: true }); 685 return; 686 } 687 688 if (courseSelectorState.loading) { 689 container.innerHTML = ` 690 <div style="text-align:center; padding:38px 20px; color:var(--text-muted);"> 691 <p style="font-size:1rem; margin:0;">${ONLINE_ONLY_MODE ? 'Loading your online courses...' : 'Checking your cloud backups...'}</p> 692 </div> 693 `; 694 return; 695 } 696 697 const rows = courseSelectorState.remoteCourses || []; 698 const knownNameMap = new Map( 699 CourseManager.getAllCourses() 700 .filter((course) => course?.id && course?.name) 701 .map((course) => [course.id, course.name]) 702 ); 703 const remoteListHtml = rows.length 704 ? rows.map((row, index) => { 705 const updated = formatSyncMoment(getRemoteUpdatedAt(row)); 706 const presentation = getRemoteCoursePresentationMeta(row, knownNameMap.get(row.course_id) || ''); 707 return ` 708 <div class="panel" style="margin:0; padding:15px; border:1px solid var(--border);"> 709 <div style="display:flex; justify-content:space-between; gap:14px; align-items:flex-start;"> 710 <div style="min-width:0;"> 711 <div style="font-size:1rem; font-weight:700; margin-bottom:6px;">${escapeAttr(presentation.courseName || `Course ${index + 1}`)}</div> 712 <div style="font-size:0.8rem; color:var(--text-muted); line-height:1.5;"> 713 Updated ${updated}<br> 714 ${row.last_device ? `Last device: ${escapeAttr(row.last_device)}` : 'Last device: not recorded'} 715 </div> 716 ${ONLINE_ONLY_MODE ? `<div style="margin-top:8px; font-size:0.76rem; color:var(--text-muted);">This course will open directly from your Supabase account.</div>` : ''} 717 </div> 718 <button class="btn btn-primary" type="button" data-action="import-remote-course-to-local" data-index="${index}" style="min-width:170px;"> 719 ${ONLINE_ONLY_MODE ? 'Open This Course' : 'Load This Backup'} 720 </button> 721 </div> 722 </div> 723 `; 724 }).join('') 725 : ` 726 <div style="text-align:center; padding:40px 20px; color:var(--text-muted);"> 727 <p style="font-size:1.1rem; margin:0;">${ONLINE_ONLY_MODE ? 'No online courses yet' : 'No cloud backups yet'}</p> 728 <p style="font-size:0.85rem; margin-top:5px;">${ONLINE_ONLY_MODE ? 'Create a course from this web version or import a JSON backup to get started.' : 'Upload a course from another device first, then come back here.'}</p> 729 </div> 730 `; 731 732 container.innerHTML = ` 733 <div style="display:grid; gap:14px;"> 734 <div style="display:flex; justify-content:space-between; gap:12px; align-items:flex-start; padding:14px 15px; border:1px solid var(--border); border-radius:12px; background:var(--input-bg);"> 735 <div> 736 <div style="font-size:0.95rem; font-weight:700; margin-bottom:4px;">Signed in as ${escapeAttr(session.user?.email || 'Unknown user')}</div> 737 <div style="font-size:0.8rem; color:var(--text-muted); line-height:1.5;">${ONLINE_ONLY_MODE ? 'Choose a course to open directly from Supabase.' : 'Choose a cloud backup to import into this browser. The original course ID will be preserved so later uploads keep syncing to the same course record.'}</div> 738 </div>
739 <span class="sync-chip">${getStoredCloudSession().storage === 'session' ? 'Shared device session' : 'Personal device session'}</span> 740 </div> 741 ${errorHtml} 742 <div style="display:flex; gap:10px; flex-wrap:wrap;"> 743 <button class="btn btn-outline" type="button" data-action="back-to-local-course-selector"> 744 Back 745 </button> 746 <button class="btn btn-outline" type="button" data-action="refresh-course-selector-remote-courses"> 747 Refresh 748 </button> 749 ${ONLINE_ONLY_MODE ? `<button class="btn btn-primary" type="button" data-action="create-new-course">Create Course</button>` : ''} 750 <button class="btn btn-outline" type="button" data-action="handle-course-selector-cloud-sign-out"> 751 Sign Out 752 </button> 753 </div> 754 <div style="display:grid; gap:10px;">${remoteListHtml}</div> 755 </div> 756 `; 757} 758 759export function openCloudCourseImport() { 760 closeCourseQuickSwitch(); 761 const session = getStoredCloudSession().session; 762 if (ONLINE_ONLY_MODE && session && isCloudAccountApproved(getCurrentCloudProfile(session))) { 763 courseSelectorState.mode = 'local'; 764 courseSelectorState.error = ''; 765 refreshCourseSelectorRemoteCourses(); 766 return; 767 } 768 769 courseSelectorState.mode = 'cloud'; 770 courseSelectorState.error = ''; 771 renderCourseSelector(); 772 773 if (session) { 774 refreshCourseSelectorRemoteCourses(); 775 } 776} 777 778export function backToLocalCourseSelector() { 779 courseSelectorState.mode = isHostedWorkspaceLocked() ? 'cloud' : 'local'; 780 courseSelectorState.loading = false; 781 courseSelectorState.error = ''; 782 renderCourseSelector(); 783} 784 785function getCourseSelectorCloudAuthValues() { 786 courseSelectorState.formEmail = document.getElementById('courseSelectorEmail')?.value?.trim() || courseSelectorState.formEmail || ''; 787 courseSelectorState.formPassword = document.getElementById('courseSelectorPassword')?.value || courseSelectorState.formPassword || ''; 788 courseSelectorState.sharedDevice = !!document.getElementById('courseSelectorSharedDevice')?.checked; 789 return { 790 email: courseSelectorState.formEmail, 791 password: courseSelectorState.formPassword, 792 storageMode: courseSelectorState.sharedDevice ? 'session' : 'local', 793 }; 794} 795 796export async function handleCourseSelectorCloudSignUp() { 797 openRequestAccessPage({ email: document.getElementById('courseSelectorEmail')?.value || '' }); 798} 799 800export async function handleCourseSelectorCloudSignIn() { 801 const { email, password, storageMode } = getCourseSelectorCloudAuthValues(); 802 if (!email || !password) { 803 courseSelectorState.error = 'Enter the same email and password you used on the other device.'; 804 renderCourseSelector(); 805 return; 806 } 807 808 courseSelectorState.loading = true; 809 courseSelectorState.error = ''; 810 renderCourseSelector(); 811 812 try { 813 const result = await supabaseAuthRequest('/auth/v1/token?grant_type=password', { 814 method: 'POST', 815 body: { email, password }, 816 }); 817 818 saveCloudSession(result, storageMode); 819 if (ONLINE_ONLY_MODE && !(await deps.ensureHostedWorkspaceReady({ forceProfile: true }))) { 820 return; 821 } 822 courseSelectorState.mode = 'cloud'; 823 await refreshCourseSelectorRemoteCourses(); 824 } catch (error) { 825 courseSelectorState.loading = false; 826 courseSelectorState.error = error.message; 827 renderCourseSelector(); 828 } 829} 830 831export async function handleCourseSelectorPasswordResetRequest() { 832 const email = document.getElementById('courseSelectorEmail')?.value?.trim() || ''; 833 if (!email) { 834 courseSelectorState.error = 'Enter your email first, then request the reset link.'; 835 renderCourseSelector(); 836 return; 837 } 838 839 const body = { email, redirect_to: getPasswordRecoveryRedirectUrl() }; 840 841 try { 842 courseSelectorState.loading = true; 843 courseSelectorState.error = ''; 844 renderCourseSelector(); 845 846 await supabaseAuthRequest('/auth/v1/recover', { 847 method: 'POST', 848 body, 849 }); 850 851 await showAlert( 852 `Password reset email sent to ${email}.\n\n` + 853 `The reset link will open the hosted Class Action reset page. ` + 854 `After choosing a new password, return to this copy and sign in again.` 855 ); 856 } catch (error) { 857 courseSelectorState.error = error.message || 'Could not send the reset email.'; 858 renderCourseSelector(); 859 } finally { 860 courseSelectorState.loading = false;
861 renderCourseSelector(); 862 } 863} 864 865export async function refreshCourseSelectorRemoteCourses() { 866 const shouldAutoEnterHostedWorkspace = ONLINE_ONLY_MODE && courseSelectorState.mode === 'cloud'; 867 courseSelectorState.loading = true; 868 courseSelectorState.error = ''; 869 renderCourseSelector(); 870 871 try { 872 if (ONLINE_ONLY_MODE) { 873 const ready = await deps.ensureHostedWorkspaceReady({ forceProfile: true }); 874 if (!ready) { 875 courseSelectorState.loading = false; 876 return; 877 } 878 courseSelectorState.remoteCourses = await deps.sync.syncOnlineOnlyRegistryFromRemote(); 879 } else { 880 courseSelectorState.remoteCourses = await deps.sync.fetchAllRemoteCourseRecords({ includeData: true }); 881 } 882 courseSelectorState.loading = false; 883 884 if (shouldAutoEnterHostedWorkspace) { 885 let activeCourseId = CourseManager.getActiveCourseId(); 886 if (!activeCourseId || !CourseManager.getCourse(activeCourseId)) { 887 const fallbackCourse = CourseManager.getAllCourses()[0]; 888 activeCourseId = courseSelectorState.remoteCourses[0]?.course_id || fallbackCourse?.id || ''; 889 if (activeCourseId) CourseManager.setActiveCourse(activeCourseId); 890 } 891 892 if (activeCourseId) { 893 closeCourseSelector(true); 894 await deps.stageWorkspaceKeyHandoffForReload(); 895 location.reload(); 896 return; 897 } 898 899 courseSelectorState.mode = 'local'; 900 renderCourseSelector(); 901 return; 902 } 903 904 renderCourseSelector(); 905 } catch (error) { 906 courseSelectorState.loading = false; 907 courseSelectorState.error = error.message; 908 renderCourseSelector(); 909 } finally { 910 deps.renderCloudSyncPanel(); 911 } 912} 913 914export async function handleCourseSelectorCloudSignOut() { 915 if (window.__DEMO_MODE) { 916 await showAlert('This is a demo â there is no active session to sign out of.'); 917 return; 918 } 919 await deps.handleCloudSignOut(); 920} 921 922export async function importRemoteCourseToLocal(index) { 923 const remote = courseSelectorState.remoteCourses[index]; 924 if (!remote?.data) return; 925 926 if (ONLINE_ONLY_MODE) { 927 try { 928 const ready = await deps.ensureHostedWorkspaceReady({ forceProfile: true }); 929 if (!ready) return; 930 const incomingCourseId = remote.course_id || ''; 931 const incomingCourseName = 932 getRemoteCoursePresentationMeta(remote || {}, CourseManager.getCourseName(incomingCourseId)).courseName || 933 remote.title || 934 'Imported Course'; 935 const ok = await showConfirm(`Open "${incomingCourseName}" from your online workspace?`); 936 if (!ok) return; 937 await selectCourse(incomingCourseId); 938 } catch (error) { 939 await showAlert(`Could not open this course.\n\n${error.message}`); 940 } 941 return; 942 } 943 944 try { 945 const resolvedRemoteData = await resolveRemoteCoursePayload(remote); 946 if (!resolvedRemoteData) return; 947 948 const incomingCourseId = resolvedRemoteData.courseId || remote.course_id; 949 const fallbackName = CourseManager.getCourse(incomingCourseId)?.name || ''; 950 const incomingCourseName = 951 resolvedRemoteData.courseName || 952 getRemoteCoursePresentationMeta(remote || {}, fallbackName).courseName || 953 remote.title || 954 'Imported Course'; 955 const localMatch = CourseManager.getCourse(incomingCourseId); 956 957 let prompt = `Load "${incomingCourseName}" from cloud backup into this browser?\n\nThis will create a local course copy that stays linked to the same course record.`; 958 if (localMatch) { 959 prompt = `A local course named "${localMatch.name}" already exists on this device with the same course ID.\n\nLoad the cloud backup and replace the local version?`; 960 } 961 962 if (!(await showConfirm(prompt))) return; 963 964 const localCourseId = CourseManager.createCourse(incomingCourseName, { 965 id: incomingCourseId, 966 created: resolvedRemoteData.created,
967 updatedAt: resolvedRemoteData.updatedAt || getRemoteUpdatedAt(remote), 968 lastAccessed: Date.now(), 969 }); 970 971 writeCourseExportData(localCourseId, resolvedRemoteData); 972 setCourseSyncMeta(localCourseId, { 973 lastKnownRemoteUpdatedAt: getRemoteUpdatedAt(remote), 974 lastSyncedLocalUpdatedAt: toTimestamp(resolvedRemoteData.updatedAt, getRemoteUpdatedAt(remote)), 975 lastRemoteCheckedAt: Date.now(), 976 lastRemoteTitle: incomingCourseName, 977 }); 978 CourseManager.setActiveCourse(localCourseId); 979 closeCourseSelector(); 980 location.reload(); 981 } catch (error) { 982 await showAlert(`Could not load this backup.\n\n${error.message}`); 983 } 984} 985 986export function initCourseSelector(options = {}) { 987 deps.getConfig = typeof options.getConfig === 'function' ? options.getConfig : (() => ({})); 988 deps.sync = options.sync || null; 989 deps.ensureHostedWorkspaceReady = typeof options.ensureHostedWorkspaceReady === 'function' ? options.ensureHostedWorkspaceReady : deps.ensureHostedWorkspaceReady; 990 deps.fetchCloudProfile = typeof options.fetchCloudProfile === 'function' ? options.fetchCloudProfile : deps.fetchCloudProfile; 991 deps.renderCloudSyncPanel = typeof options.renderCloudSyncPanel === 'function' ? options.renderCloudSyncPanel : deps.renderCloudSyncPanel; 992 deps.handleCloudSignOut = typeof options.handleCloudSignOut === 'function' ? options.handleCloudSignOut : deps.handleCloudSignOut; 993 deps.stageWorkspaceKeyHandoffForReload = typeof options.stageWorkspaceKeyHandoffForReload === 'function' 994 ? options.stageWorkspaceKeyHandoffForReload 995 : deps.stageWorkspaceKeyHandoffForReload; 996 997 renderCourseQuickSwitch(); 998}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.