1/** 2 * Sources Tab â link documents/URLs to a feature. 3 * Admin-only tab in the feature detail modal. 4 * Provides: list existing sources, add from library (typeahead), 5 * add external URL, edit page reference/note, delete. 6 */ 7 8let _featureId = null; 9 10export function bindSourcesTab(featureId, isAdmin) { 11 const tabItem = document.getElementById('feature-tab-sources-item'); 12 if (!tabItem) return; 13 14 // Gated by per-project Interface setting. Default OFF so the Sources 15 // tab doesn't show up on every project â most projects don't track 16 // citations. Existing sources stay in the database when off, just hidden. 17 if (!window.projectSettings?.enable_sources_tab) { 18 tabItem.classList.add('d-none'); 19 return; 20 } 21 22 // Show tab for admins (or always for now â sources are read-only for public) 23 tabItem.classList.remove('d-none'); 24 _featureId = featureId; 25 26 // Load on tab show 27 const btn = document.getElementById('feature-tab-sources-btn'); 28 btn?.addEventListener('shown.bs.tab', () => loadSources(featureId, isAdmin), { once: true }); 29 30 // Also load immediately if tab is already active (edge case) 31 const pane = document.getElementById('feature-tab-sources'); 32 if (pane?.classList.contains('active')) loadSources(featureId, isAdmin); 33} 34 35async function loadSources(featureId, isAdmin) { 36 const container = document.getElementById('feature-modal-sources-content'); 37 if (!container) return; 38 39 container.innerHTML = '<div class="text-muted small p-2"><i class="fas fa-spinner fa-spin me-1"></i>Loading sources...</div>'; 40 41 try { 42 const res = await fetch(`/api/features/${featureId}/sources`); 43 if (!res.ok) throw new Error('Failed to load'); 44 const sources = await res.json(); 45 renderSourcesList(container, sources, featureId, isAdmin); 46 } catch (e) { 47 container.innerHTML = '<div class="text-danger small p-2">Failed to load sources.</div>'; 48 } 49} 50
51function renderSourcesList(container, sources, featureId, isAdmin) { 52 let html = ''; 53 54 if (sources.length) { 55 html += '<ul class="list-unstyled mb-2">'; 56 for (const s of sources) { 57 const icon = getIcon(s); 58 const title = s.title || s.library_name || s.url || 'Untitled'; 59 const href = s.url || s.library_file_path || s.library_url || null; 60 const link = href 61 ? `<a href="${esc(href)}" target="_blank" class="text-decoration-none">${esc(title)}</a>` 62 : `<span>${esc(title)}</span>`; 63 const pageRef = s.page_ref ? `<span class="text-muted ms-1">(p. ${esc(s.page_ref)})</span>` : ''; 64 const note = s.note ? `<div class="text-muted small">${esc(s.note)}</div>` : ''; 65 const deleteBtn = isAdmin 66 ? `<button class="btn btn-sm btn-outline-danger ms-auto src-delete" data-id="${s.id}" title="Remove"><i class="fas fa-times"></i></button>` 67 : ''; 68 69 html += `<li class="d-flex align-items-start gap-2 py-1 border-bottom"> 70 <i class="fas ${icon} text-muted mt-1" style="width:16px;text-align:center;"></i> 71 <div class="flex-grow-1">${link}${pageRef}${note}</div> 72 ${deleteBtn} 73 </li>`; 74 } 75 html += '</ul>'; 76 } else { 77 html += '<p class="text-muted small mb-2">No sources linked to this feature.</p>'; 78 } 79 80 if (isAdmin) { 81 html += ` 82 <div class="border-top pt-2"> 83 <label class="form-label small fw-semibold mb-1"><i class="fas fa-plus me-1"></i>Add Source</label> 84 <p class="text-muted small mb-1">Type a name or paste a URL, then press the <strong>+</strong> button to link it.</p> 85 <div class="input-group input-group-sm mb-1"> 86 <input type="text" id="src-search" class="form-control" placeholder="Search library or paste URL..."> 87 <button class="btn btn-outline-primary" id="src-add-btn" type="button"><i class="fas fa-plus"></i></button> 88 </div> 89 <ul id="src-suggestions" class="list-group position-absolute shadow-sm" style="z-index:1050;max-height:200px;overflow-y:auto;display:none;"></ul> 90 <div class="row g-1 mb-1"> 91 <div class="col-4"> 92 <input type="text" id="src-page-ref" class="form-control form-control-sm" placeholder="Page/section"> 93 </div> 94 <div class="col-8"> 95 <input type="text" id="src-note" class="form-control form-control-sm" placeholder="Note about this source"> 96 </div> 97 </div> 98 </div>`; 99 } 100 101 container.innerHTML = html; 102 103 // Wire delete buttons 104 container.querySelectorAll('.src-delete').forEach(btn => { 105 btn.addEventListener('click', async () => { 106 const id = btn.dataset.id; 107 if (!confirm('Remove this source?')) return; 108 try { 109 await fetch(`/api/features/${featureId}/sources/${id}`, { method: 'DELETE' }); 110 loadSources(featureId, isAdmin); 111 } catch (e) { 112 console.error('[sources] Delete failed:', e); 113 } 114 }); 115 }); 116 117 // Wire add source 118 if (!isAdmin) return; 119 120 const searchInput = document.getElementById('src-search'); 121 const sugList = document.getElementById('src-suggestions'); 122 const addBtn = document.getElementById('src-add-btn'); 123 let selectedLibraryId = null; 124 let debounceTimer = null; 125 126 searchInput?.addEventListener('input', () => { 127 selectedLibraryId = null; 128 clearTimeout(debounceTimer); 129 const q = searchInput.value.trim(); 130 if (q.length < 2) { sugList.style.display = 'none'; return; } 131 132 debounceTimer = setTimeout(async () => { 133 try { 134 const res = await fetch(`/api/library/search?q=${encodeURIComponent(q)}&limit=8`); 135 if (!res.ok) return; 136 const results = await res.json(); 137 if (!results.length) { sugList.style.display = 'none'; return; } 138 139 sugList.innerHTML = results.map(r => ` 140 <button type="button" class="list-group-item list-group-item-action small d-flex align-items-center gap-2" 141 data-lib-id="${r.id}" data-lib-name="${esc(r.name)}"> 142 <i class="fas ${getIconForType(r.type)} text-muted"></i> 143 <span class="flex-grow-1">${esc(r.name)}</span>
144 <span class="badge bg-secondary">${r.type}</span> 145 </button>`).join(''); 146 sugList.style.display = ''; 147 148 sugList.querySelectorAll('button').forEach(btn => { 149 btn.addEventListener('click', () => { 150 selectedLibraryId = Number(btn.dataset.libId); 151 searchInput.value = btn.dataset.libName; 152 sugList.style.display = 'none'; 153 }); 154 }); 155 } catch (e) { sugList.style.display = 'none'; } 156 }, 300); 157 }); 158 159 // Close suggestions on blur 160 searchInput?.addEventListener('blur', () => { 161 setTimeout(() => { sugList.style.display = 'none'; }, 200); 162 }); 163 164 addBtn?.addEventListener('click', async () => { 165 const val = searchInput.value.trim(); 166 if (!val) return; 167 168 const pageRef = document.getElementById('src-page-ref')?.value.trim() || null; 169 const note = document.getElementById('src-note')?.value.trim() || null; 170 171 // Determine if it's a URL or a library reference 172 const isUrl = /^https?:\/\//i.test(val); 173 const body = {}; 174 175 if (selectedLibraryId) { 176 body.library_id = selectedLibraryId; 177 body.source_type = 'library'; 178 body.title = val; 179 } else if (isUrl) { 180 body.source_type = 'url'; 181 body.url = val; 182 body.title = val; 183 } else { 184 body.source_type = 'text'; 185 body.title = val; 186 } 187 188 if (pageRef) body.page_ref = pageRef; 189 if (note) body.note = note; 190 191 try { 192 addBtn.disabled = true; 193 const res = await fetch(`/api/features/${featureId}/sources`, { 194 method: 'POST', 195 headers: { 'Content-Type': 'application/json' }, 196 body: JSON.stringify(body) 197 }); 198 if (!res.ok) throw new Error('Failed to add'); 199 loadSources(featureId, isAdmin); 200 } catch (e) { 201 console.error('[sources] Add failed:', e); 202 } finally { 203 addBtn.disabled = false; 204 } 205 }); 206} 207 208function getIcon(s) { 209 return getIconForType(s.library_type || s.source_type); 210} 211 212function getIconForType(type) { 213 if (type === 'pdf') return 'fa-file-pdf'; 214 if (type === 'url') return 'fa-link'; 215 if (type === 'image') return 'fa-file-image'; 216 if (type === 'geojson') return 'fa-globe'; 217 if (type === 'text') return 'fa-file-lines'; 218 return 'fa-file'; 219} 220 221function esc(s) { 222 return String(s || '').replace(/[&<>"']/g, m => 223 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); 224}
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.