PageSourceSearch

https://expertle.ai/js/generators/resources.js

js expertle.ai collected 2026-10-05 15:16:31 UTC 19,848 bytes, 374 lines download raw bytes

1// ═══════════════════════════════════════════════════════════════
2// EXPERTLE — Course Resources (v2.0.38)
3// Trainers add videos (YouTube/Vimeo/Loom), links, and file
4// uploads. Stored on unit doc, copied to published_courses on
5// publish. Students access from the course hub.
6// ═══════════════════════════════════════════════════════════════
7
8import { toast, escHtml } from '../utils.js';
9import { getState, getDb } from '../app.js';
10
11let resources = [];
12
13// ── Video URL helpers ──────────────────────────────────────────
14
15function parseVideoEmbed(url) {
16  if (!url) return null;
17  const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
18  if (ytMatch) return { provider: 'YouTube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}`, id: ytMatch[1], thumb: `https://img.youtube.com/vi/${ytMatch[1]}/mqdefault.jpg` };
19  const vmMatch = url.match(/vimeo\.com\/(\d+)/);
20  if (vmMatch) return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}`, id: vmMatch[1], thumb: null };
21  const lmMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/);
22  if (lmMatch) return { provider: 'Loom', embedUrl: `https://www.loom.com/embed/${lmMatch[1]}`, id: lmMatch[1], thumb: `https://cdn.loom.com/sessions/thumbnails/${lmMatch[1]}-with-play.gif` };
23  return null;
24}
25
26function getFileIcon(fileName) {
27  const ext = (fileName || '').split('.').pop().toLowerCase();
28  const icons = { pdf: '📕', doc: '📘', docx: '📘', xls: '📗', xlsx: '📗', csv: '📗', ppt: '📙', pptx: '📙', txt: '📝', zip: '📦' };
29  return icons[ext] || '📄';
30}
31
32function getFileExt(fileName) {
33  return (fileName || '').split('.').pop().toUpperCase();
34}
35
36// ── Modal helper ───────────────────────────────────────────────
37
38function showResourceModal({ title, fields, submitLabel, onSubmit }) {
39  document.getElementById('res-modal-overlay')?.remove();
40
41  const fieldHtml = fields.map(f => `
42    <div style="margin-bottom:14px;">
43      <label style="display:block;font-size:12px;font-weight:600;color:var(--text,#e0e0e8);margin-bottom:4px;">${escHtml(f.label)}${f.required ? ' <span style="color:#ef4444;">*</span>' : ''}</label>
44      ${f.type === 'textarea'
45        ? `<textarea id="res-field-${f.id}" placeholder="${escHtml(f.placeholder || '')}" style="width:100%;height:80px;background:var(--surface-2,#0f1117);color:var(--text,#e0e0e8);border:1px solid var(--border,#2a2a3e);border-radius:8px;padding:10px;font-size:13px;font-family:inherit;resize:vertical;box-sizing:border-box;">${escHtml(f.value || '')}</textarea>`
46        : `<input id="res-field-${f.id}" type="${f.type || 'text'}" value="${escHtml(f.value || '')}" placeholder="${escHtml(f.placeholder || '')}" style="width:100%;background:var(--surface-2,#0f1117);color:var(--text,#e0e0e8);border:1px solid var(--border,#2a2a3e);border-radius:8px;padding:10px;font-size:13px;font-family:inherit;box-sizing:border-box;">`}
47      ${f.hint ? `<div style="font-size:11px;color:var(--text-muted,#6b6b80);margin-top:3px;">${f.hint}</div>` : ''}
48      <div id="res-field-${f.id}-preview" style="margin-top:8px;"></div>
49    </div>`).join('');
50
51  const overlay = document.createElement('div');
52  overlay.id = 'res-modal-overlay';
53  overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);';
54  overlay.innerHTML = `
55    <div style="background:var(--surface-1,#1a1a2e);border:1px solid var(--border,#2a2a3e);border-radius:12px;width:90%;max-width:480px;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,0.5);">
56      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
57        <h3 style="margin:0;font-size:17px;color:var(--text,#e0e0e8);">${escHtml(title)}</h3>
58        <button id="res-modal-close" style="background:none;border:none;color:var(--text-muted,#a0a0b8);font-size:20px;cursor:pointer;padding:4px;">✕</button>
59      </div>
60      ${fieldHtml}
61      <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:18px;">
62        <button id="res-modal-cancel" class="btn btn-secondary" style="padding:8px 16px;font-size:13px;">Cancel</button>
63        <button id="res-modal-submit" class="btn" style="padding:8px 20px;font-size:13px;background:linear-gradient(135deg,#7C5CFC,#a78bfa);color:#fff;border:none;border-radius:var(--radius-sm,6px);font-weight:600;cursor:pointer;">${escHtml(submitLabel)}</button>
64      </div>
65    </div>`;
66
67  document.body.appendChild(overlay);
68
69  const close = () => overlay.remove();
70  overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
71  document.getElementById('res-modal-close').addEventListener('click', close);
72  document.getElementById('res-modal-cancel').addEventListener('click', close);
73  document.getElementById('res-modal-submit').addEventListener('click', () => {
74    const values = {};
75    fields.forEach(f => { values[f.id] = document.getElementById(`res-field-${f.id}`)?.value?.trim() || ''; });
76    onSubmit(values, close);
77  });
78
79  // Focus first field
80  setTimeout(() => document.getElementById(`res-field-${fields[0].id}`)?.focus(), 50);
81  return overlay;
82}
83
84// ── Init ───────────────────────────────────────────────────────
85
86export function initResources() {
87  document.getElementById('res-add-video')?.addEventListener('click', addVideoResource);
88  document.getElementById('res-add-link')?.addEventListener('click', addLinkResource);
89  document.getElementById('res-add-file')?.addEventListener('click', () => {
90    document.getElementById('res-file-input')?.click();
91  });
92  document.getElementById('res-file-input')?.addEventListener('change', async (e) => {
93    const file = e.target.files[0];
94    if (file) await addFileResource(file);
95    e.target.value = '';
96  });
97}
98
99// ── Load from unit ─────────────────────────────────────────────
100
101export function loadResourcesFromUnit(unitData) {
102  resources = Array.isArray(unitData?.resources) ? [...unitData.resources] : [];
103  renderResourceList();
104}
105
106// ── Render ─────────────────────────────────────────────────────
107
108function renderResourceList() {
109  const container = document.getElementById('res-list');
110  if (!container) return;
111
112  const emptyState = document.getElementById('res-empty-state');
113  if (emptyState) emptyState.style.display = resources.length ? 'none' : 'block';
114
115  const cards = resources.map((r, i) => {
116    let thumbnail = '';
117    let typeLabel = '';
118    let subtitle = '';
119
120    if (r.type === 'video') {
121      const parsed = parseVideoEmbed(r.url);
122      typeLabel = parsed ? parsed.provider : 'Video';
123      subtitle = r.description || r.url || '';
124      if (parsed?.thumb) {
125        thumbnail = `<div style="width:96px;height:54px;flex-shrink:0;border-radius:6px;overflow:hidden;background:#000;">
126          <img src="${escHtml(parsed.thumb)}" style="width:100%;height:100%;object-fit:cover;" onerror="this.parentElement.innerHTML='<div style=\\'display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:24px;\\'>🎬</div>'">
127        </div>`;
128      } else {
129        thumbnail = `<div style="width:96px;height:54px;flex-shrink:0;border-radius:6px;background:linear-gradient(135deg,#1e1e30,#2a2a3e);display:flex;align-items:center;justify-content:center;font-size:28px;">▶</div>`;
130      }
131    } else if (r.type === 'link') {
132      typeLabel = 'Link';
133      subtitle = r.description || '';
134      const domain = (() => { try { return new URL(r.url).hostname; } catch { return r.url; } })();
135      thumbnail = `<div style="width:48px;height:48px;flex-shrink:0;border-radius:8px;background:linear-gradient(135deg,#312e81,#4338ca);
135display:flex;align-items:center;justify-content:center;">
136        <img src="https://www.google.com/s2/favicons?domain=${escHtml(domain)}&sz=32" style="width:24px;height:24px;border-radius:2px;" onerror="this.outerHTML='<span style=\\'font-size:20px;\\'>🔗</span>'">
137      </div>`;
138    } else if (r.type === 'file') {
139      const icon = getFileIcon(r.fileName);
140      const ext = getFileExt(r.fileName);
141      typeLabel = ext || 'File';
142      subtitle = r.fileName || '';
143      thumbnail = `<div style="width:48px;height:48px;flex-shrink:0;border-radius:8px;background:linear-gradient(135deg,#1e3a2f,#065f46);display:flex;align-items:center;justify-content:center;font-size:24px;">${icon}</div>`;
144    }
145
146    return `<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;margin-bottom:8px;background:var(--surface-2,#1a1a2e);border:1px solid var(--border,#2a2a3e);border-radius:10px;transition:border-color 0.2s;" onmouseover="this.style.borderColor='#4338ca'" onmouseout="this.style.borderColor='var(--border,#2a2a3e)'" data-res-idx="${i}">
147      ${thumbnail}
148      <div style="flex:1;min-width:0;">
149        <div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">
150          <span style="font-size:14px;font-weight:600;color:var(--text,#e0e0e8);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(r.title)}</span>
151          <span style="font-size:10px;font-weight:700;color:var(--text-muted,#6b6b80);background:var(--surface-1,#0f1117);padding:2px 6px;border-radius:4px;flex-shrink:0;text-transform:uppercase;letter-spacing:0.5px;">${escHtml(typeLabel)}</span>
152        </div>
153        ${subtitle ? `<div style="font-size:12px;color:var(--text-muted,#a0a0b8);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(subtitle)}</div>` : ''}
154      </div>
155      <div style="display:flex;gap:3px;flex-shrink:0;">
156        ${i > 0 ? `<button class="res-move-up" data-idx="${i}" title="Move up" style="background:none;border:1px solid var(--border,#2a2a3e);color:var(--text-muted,#a0a0b8);border-radius:6px;width:28px;height:28px;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;">↑</button>` : ''}
157        ${i < resources.length - 1 ? `<button class="res-move-down" data-idx="${i}" title="Move down" style="background:none;border:1px solid var(--border,#2a2a3e);color:var(--text-muted,#a0a0b8);border-radius:6px;width:28px;height:28px;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;">↓</button>` : ''}
158        <button class="res-delete" data-idx="${i}" title="Remove" style="background:none;border:1px solid rgba(239,68,68,0.3);color:#ef4444;border-radius:6px;width:28px;height:28px;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;">✕</button>
159      </div>
160    </div>`;
161  }).join('');
162
163  const cardsContainer = container.querySelector('.res-cards') || (() => {
164    const div = document.createElement('div');
165    div.className = 'res-cards';
166    container.appendChild(div);
167    return div;
168  })();
169  cardsContainer.innerHTML = cards;
170
171  cardsContainer.querySelectorAll('.res-delete').forEach(btn => {
172    btn.addEventListener('click', () => removeResource(parseInt(btn.dataset.idx)));
173  });
174  cardsContainer.querySelectorAll('.res-move-up').forEach(btn => {
175    btn.addEventListener('click', () => moveResource(parseInt(btn.dataset.idx), -1));
176  });
177  cardsContainer.querySelectorAll('.res-move-down').forEach(btn => {
178    btn.addEventListener('click', () => moveResource(parseInt(btn.dataset.idx), 1));
179  });
180}
181
182// ── Add Video ──────────────────────────────────────────────────
183
184function addVideoResource() {
185  const modal = showResourceModal({
186    title: '🎬 Add Video',
187    fields: [
188      { id: 'url', label: 'Video URL', placeholder: 'https://www.youtube.com/watch?v=...', required: true, hint: 'Supports YouTube, Vimeo, and Loom links' },
189      { id: 'title', label: 'Title', placeholder: 'e.g. WHS Induction Overview', required: true },
190      { id: 'description', label: 'Description', placeholder: 'e.g. Watch this 5-min explainer before starting Section 2...', type: 'textarea' }
191    ],
192    submitLabel: 'Add Video',
193    onSubmit: (values, close) => {
194      if (!values.url) { toast('URL is required', 'warning'); return; }
195      const parsed = parseVideoEmbed(values.url);
196      if (!parsed) { toast('Not a valid YouTube, Vimeo, or Loom URL', 'error'); return; }
197      resources.push({
198        id: crypto.randomUUID?.() || String(Date.now()),
199        type: 'video',
200        title: values.title || `${parsed.provider} Video`,
201        description: values.description || '',
202        url: values.url,
203        order: resources.length
204      });
205      renderResourceList();
206      saveResources();
207      toast('Video added', 'success');
208      close();
209    }
210  });
211
212  // Live preview: show thumbnail when URL is pasted
213  const urlField = document.getElementById('res-field-url');
214  const previewEl = document.getElementById('res-field-url-preview');
215  if (urlField && previewEl) {
216    urlField.addEventListener('input', () => {
217      const parsed = parseVideoEmbed(urlField.value.trim());
218      if (parsed) {
219        const thumbSrc = parsed.thumb || '';
220        previewEl.innerHTML = `<div style="display:flex;align-items:center;gap:8px;padding:8px;background:var(--surface-2,#0f1117);border-radius:6px;">
221          ${thumbSrc ? `<img src="${escHtml(thumbSrc)}" style="width:80px;height:45px;object-fit:cover;border-radius:4px;">` : ''}
222          <div style="font-size:12px;color:#22c55e;">✓ Valid ${parsed.provider} URL detected</div>
223        </div>`;
224        // Auto-fill title if empty
225        const titleField = document.getElementById('res-field-title');
226        if (titleField && !titleField.value) titleField.placeholder = `${parsed.provider} Video`;
227      } else if (urlField.value.trim()) {
228        previewEl.innerHTML = `<div style="font-size:12px;color:#ef4444;padding:4px 0;">Not a recognised video URL</div>`;
229      } else {
230        previewEl.innerHTML = '';
231      }
232    });
233  }
234}
235
236// ── Add Link ───────────────────────────────────────────────────
237
238function addLinkResource() {
239  showResourceModal({
240    title: '🔗 Add Link',
241    fields: [
242      { id: 'url', label: 'URL', placeholder: 'https://www.safeworkaustralia.gov.au/...', required: true },
243      { id: 'title', label: 'Title', placeholder: 'e.g. Safe Work Australia — WHS Codes of Practice', required: true },
244      { id: 'description', label: 'Description', placeholder: 'Brief description of what this resource covers...', type: 'textarea' }
245    ],
246    submitLabel: 'Add Link',
247    onSubmit: (values, close) => {
248      if (!values.url) { toast('URL is required', 'warning'); return; }
249      if (!values.title) { toast('Title is required', 'warning'); return; }
250      resources.push({
251        id: crypto.randomUUID?.() || String(Date.now()),
252        type: 'link',
253        title: values.title,
254        description: values.description,
255        url: values.url.startsWith('http') ? values.url : `https://${values.url}`,
256        order: resources.length
257      });
258      renderResourceList();
259      saveResources();
260      toast('Link added', 'success');
261      close();
262    }
263  });
264}
265
266// ── Add File ───────────────────────────────────────────────────
267
268async function addFileResource(file) {
269  if (file.size > 10 * 1024 * 1024) {
270    toast(`File too large (${(file.size / 1024 / 1024).toFixed(1)}MB) — max 10MB`, 'error');
271    return;
272  }
273
274  const { user, currentUnit } = getState();
275  if (!user?.uid || !currentUnit?.id) {
276    toast('Not logged in or no unit selected', 'error');
277    return;
278  }
279
280  const icon = getFileIcon(file.name);
281  const ext = getFileExt(file.name);
282  const sizeMB = (file.size / 1024 / 1024).toFixed(1);
283
284  showResourceModal({
285    title: `📄 Upload File`,
286    fields: [
287      { id: 'title', label: 'Title', value: file.name.replace(/\.[^.]+$/, ''), placeholder: 'e.g. Risk Assessment Template', required: true, hint: `${icon} ${file.name} (${sizeMB}MB, ${ext})` },
288      { id: 'description', label: 'Description', placeholder: 'e.g. Use this template for your practical assessment...', type: 'textarea' }
289    ],
290    submitLabel: 'Upload',
291    onSubmit: async (values, close) => {
292      if (!values.title) { toast('Title is required', 'warning'); return; }
293
294      const submitBtn = document.getElementById('res-modal-submit');
295      submitBtn.disabled = true;
296      submitBtn.textContent = 'Uploading...';
297
298      try {
299        const storagePath = `course-resources/${user.uid}/${currentUnit.id}/${Date.now()}_${file.name}`;
300        const ref = firebase.storage().ref(storagePath);
301        await ref.put(file);
302        const url = await ref.getDownloadURL();
303
304        resources.push({
305          id: crypto.randomUUID?.() || String(Date.now()),
306          type: 'file',
307          title: values.title,
308          description: values.description || '',
309          url,
310          fileName: file.name,
311          storagePath,
312          order: resources.length
313        });
314        renderResourceList();
315        saveResources();
316        toast('File uploaded!', 'success');
317        close();
318      } catch (err) {
319        console.error('[Expertle] Resource file upload error:', err);
320        submitBtn.disabled = false;
321        submitBtn.textContent = 'Upload';
322        toast('Upload failed: ' + err.message, 'error');
323      }
324    }
325  });
326}
327
328// ── Remove ─────────────────────────────────────────────────────
329
330async function removeResource(idx) {
331  const r = resources[idx];
332  if (!r) return;
333  if (!confirm(`Remove "${r.title}"?`)) return;
334
335  if (r.type === 'file' && r.storagePath) {
336    try {
337      await firebase.storage().ref(r.storagePath).delete();
338    } catch (err) {
339      console.warn('[Expertle] Could not delete resource file from storage:', err);
340    }
341  }
342
343  resources.splice(idx, 1);
344  resources.forEach((r, i) => r.order = i);
345  renderResourceList();
346  saveResources();
347  toast('Resource removed', 'success');
348}
349
350// ── Reorder ────────────────────────────────────────────────────
351
352function moveResource(idx, direction) {
353  const newIdx = idx + direction;
354  if (newIdx < 0 || newIdx >= resources.length) return;
355  [resources[idx], resources[newIdx]] = [resources[newIdx], resources[idx]];
356  resources.forEach((r, i) => r.order = i);
357  renderResourceList();
358  saveResources();
359}
360
361// ── Save to Firestore ──────────────────────────────────────────
362
363async function saveResources() {
364  const { currentUnit } = getState();
365  if (!currentUnit?.id) return;
366  try {
367    const db = getDb();
368    await db.collection('units').doc(currentUnit.id).update({ resources });
369    currentUnit.resources = resources;
370  } catch (err) {
371    console.error('[Expertle] Save resources error:', err);
372    toast('Failed to save resources', 'error');
373  }
374}

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.