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.