PageSourceSearch

https://classaction.cv/app/js/ui/course-selector.js

js classaction.cv collected 2026-10-07 13:18:30 UTC 38,568 bytes, 998 lines download raw bytes

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, '&lt;')
66    .replace(/>/g, '&gt;')
67    .replace(/"/g, '&quot;');
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.