PageSourceSearch

https://bikeatthebeach.com/js/map-view/details/sources-tab.js

js bikeatthebeach.com collected 2026-09-24 13:58:11 UTC 8,407 bytes, 224 lines download raw bytes

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    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.