| 1 |
|
| 2 |
* Item details tab: bundle management, section editing, tag search. |
| 3 |
* |
| 4 |
* Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap |
| 5 |
* via htmx:afterSwap. Reads item ID from data-item-id on the container. |
| 6 |
|
| 7 |
(function() { |
| 8 |
function init() { |
| 9 |
var container = document.getElementById('item-details-tab'); |
| 10 |
if (!container) return; |
| 11 |
var itemId = container.dataset.itemId; |
| 12 |
if (!itemId) return; |
| 13 |
|
| 14 |
try { initBundles(itemId); } catch(e) { console.error('initBundles failed:', e); } |
| 15 |
initSections(itemId); |
| 16 |
initTagSearch(itemId); |
| 17 |
initAiTierToggle(); |
| 18 |
initItemImage(itemId); |
| 19 |
} |
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
function initBundles(bundleId) { |
| 24 |
|
| 25 |
var addBtn = document.getElementById('bundle-add-btn'); |
| 26 |
if (addBtn) { |
| 27 |
addBtn.addEventListener('click', function() { |
| 28 |
var select = document.getElementById('bundle-add-select'); |
| 29 |
var itemId = select.value; |
| 30 |
if (!itemId) return; |
| 31 |
this.disabled = true; |
| 32 |
|
| 33 |
fetch('/api/items/' + bundleId + '/bundle/add', { |
| 34 |
method: 'POST', |
| 35 |
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), |
| 36 |
body: JSON.stringify({ item_id: itemId }) |
| 37 |
}) |
| 38 |
.then(function(res) { |
| 39 |
if (!res.ok) return apiErrorMessage(res, 'Failed to add').then(function(m) { throw new Error(m); }); |
| 40 |
select.remove(select.selectedIndex); |
| 41 |
select.value = ''; |
| 42 |
var empty = document.getElementById('bundle-empty'); |
| 43 |
if (empty) empty.remove(); |
| 44 |
|
| 45 |
var detailsBtn = document.getElementById('tab-details'); |
| 46 |
if (detailsBtn) detailsBtn.click(); |
| 47 |
}) |
| 48 |
.catch(function(err) { showToast(err.message); }) |
| 49 |
.finally(function() { addBtn.disabled = false; }); |
| 50 |
}); |
| 51 |
} |
| 52 |
|
| 53 |
|
| 54 |
var addRowBtn = document.getElementById('bundle-add-row-btn'); |
| 55 |
if (addRowBtn && !addRowBtn.dataset.bound) { |
| 56 |
addRowBtn.dataset.bound = '1'; |
| 57 |
addRowBtn.addEventListener('click', function() { |
| 58 |
var container = document.getElementById('bundle-new-rows'); |
| 59 |
var row = document.createElement('div'); |
| 60 |
row.style.cssText = 'display: grid; grid-template-columns: 1fr 1.5fr auto; gap: 0.5rem; align-items: start; padding: 0.5rem 0; border-bottom: 1px solid var(--border);'; |
| 61 |
row.innerHTML = |
| 62 |
'<input type="text" class="bundle-new-title" placeholder="Item name" autocomplete="off">' + |
| 63 |
'<input type="text" class="bundle-new-desc" placeholder="Description (optional)" autocomplete="off">' + |
| 64 |
'<div style="display: flex; gap: 0.25rem;">' + |
| 65 |
'<button type="button" class="btn-primary bundle-create-btn" style="padding: 0.3rem 0.7rem; font-size: 0.85rem;">Create</button>' + |
| 66 |
'<button type="button" class="btn-secondary bundle-cancel-btn" style="padding: 0.3rem 0.5rem; font-size: 0.85rem;">Cancel</button>' + |
| 67 |
'</div>'; |
| 68 |
container.appendChild(row); |
| 69 |
|
| 70 |
var titleInput = row.querySelector('.bundle-new-title'); |
| 71 |
titleInput.focus(); |
| 72 |
|
| 73 |
row.querySelector('.bundle-cancel-btn').addEventListener('click', function() { |
| 74 |
row.remove(); |
| 75 |
}); |
| 76 |
|
| 77 |
row.querySelector('.bundle-create-btn').addEventListener('click', function() { |
| 78 |
var title = titleInput.value.trim(); |
| 79 |
if (!title) { titleInput.focus(); return; } |
| 80 |
var desc = row.querySelector('.bundle-new-desc').value.trim(); |
| 81 |
var btn = this; |
| 82 |
btn.disabled = true; |
| 83 |
btn.textContent = '...'; |
| 84 |
|
| 85 |
fetch('/api/items/' + bundleId + '/bundle/create-child', { |
| 86 |
method: 'POST', |
| 87 |
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), |
| 88 |
body: JSON.stringify({ title: title, description: desc || null }) |
| 89 |
}) |
| 90 |
.then(function(res) { |
| 91 |
if (!res.ok) return res.json().then(function(d) { throw new Error(d.error || 'Failed'); }); |
| 92 |
return res.json(); |
| 93 |
}) |
| 94 |
.then(function(data) { |
| 95 |
row.remove(); |
| 96 |
var empty = document.getElementById('bundle-empty'); |
| 97 |
if (empty) empty.remove(); |
| 98 |
|
| 99 |
|
| 100 |
var tbody = document.getElementById('bundle-items-list'); |
| 101 |
var tr = document.createElement('tr'); |
| 102 |
tr.className = 'bundle-row'; |
| 103 |
tr.dataset.childId = data.item_id; |
| 104 |
tr.style.borderBottom = '1px solid var(--border)'; |
| 105 |
tr.innerHTML = |
| 106 |
'<td style="padding: 0.5rem 0.5rem 0.5rem 0;"><a href="/dashboard/item/' + data.item_id + '">' + escapeHtml(data.title) + '</a></td>' + |
| 107 |
'<td style="padding: 0.5rem; font-size: 0.85rem; opacity: 0.8;">' + escapeHtml(desc) + '</td>' + |
| 108 |
'<td style="padding: 0.5rem; font-size: 0.85rem;"><a href="/dashboard/item/' + data.item_id + '" style="font-size: 0.8rem;">Manage files</a></td>' + |
| 109 |
'<td style="padding: 0.5rem;"><button type="button" class="btn-secondary bundle-remove-btn" data-child-id="' + data.item_id + '" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;">Remove</button></td>'; |
| 110 |
tbody.appendChild(tr); |
| 111 |
attachBundleRowHandlers(tr, bundleId); |
| 112 |
updateBundleCount(1); |
| 113 |
}) |
| 114 |
.catch(function(err) { |
| 115 |
btn.disabled = false; |
| 116 |
btn.textContent = 'Create'; |
| 117 |
showToast(err.message); |
| 118 |
}); |
| 119 |
}); |
| 120 |
|
| 121 |
|
| 122 |
titleInput.addEventListener('keydown', function(e) { |
| 123 |
if (e.key === 'Enter') { e.preventDefault(); row.querySelector('.bundle-create-btn').click(); } |
| 124 |
}); |
| 125 |
row.querySelector('.bundle-new-desc').addEventListener('keydown', function(e) { |
| 126 |
if (e.key === 'Enter') { e.preventDefault(); row.querySelector('.bundle-create-btn').click(); } |
| 127 |
}); |
| 128 |
}); |
| 129 |
} |
| 130 |
|
| 131 |
|
| 132 |
document.querySelectorAll('.bundle-row').forEach(function(row) { |
| 133 |
attachBundleRowHandlers(row, bundleId); |
| 134 |
}); |
| 135 |
} |
| 136 |
|
| 137 |
function attachBundleRowHandlers(row, bundleId) { |
| 138 |
var removeBtn = row.querySelector('.bundle-remove-btn'); |
| 139 |
if (removeBtn) { |
| 140 |
removeBtn.addEventListener('click', function() { |
| 141 |
var childId = this.dataset.childId; |
| 142 |
fetch('/api/items/' + bundleId + '/bundle/' + childId, { |
| 143 |
method: 'DELETE', |
| 144 |
headers: csrfHeaders() |
| 145 |
}) |
| 146 |
.then(function(res) { |
| 147 |
if (!res.ok) return apiErrorMessage(res, 'Failed to remove').then(function(m) { throw new Error(m); }); |
| 148 |
row.remove(); |
| 149 |
updateBundleCount(-1); |
| 150 |
}) |
| 151 |
.catch(function(err) { showToast(err.message); }); |
| 152 |
}); |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
function updateBundleCount(delta) { |
| 157 |
var el = document.getElementById('bundle-count'); |
| 158 |
if (el) el.textContent = parseInt(el.textContent) + delta; |
| 159 |
} |
| 160 |
|
| 161 |
|
| 162 |
|
| 163 |
function initSections(itemId) { |
| 164 |
var addBtn = document.getElementById('add-sec-btn'); |
| 165 |
if (!addBtn) return; |
| 166 |
|
| 167 |
addBtn.addEventListener('click', function() { |
| 168 |
var title = document.getElementById('new-sec-title').value.trim(); |
| 169 |
var body = document.getElementById('new-sec-body').value; |
| 170 |
var status = document.getElementById('sec-add-status'); |
| 171 |
if (!title) { status.textContent = 'Title is required'; return; } |
| 172 |
this.disabled = true; |
| 173 |
status.textContent = ''; |
| 174 |
|
| 175 |
fetch('/api/items/' + itemId + '/sections', { |
| 176 |
method: 'POST', |
| 177 |
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), |
| 178 |
body: JSON.stringify({ title: title, body: body }) |
| 179 |
}) |
| 180 |
.then(function(res) { |
| 181 |
if (!res.ok) return res.json().then(function(d) { throw new Error(d.error || 'Failed'); }); |
| 182 |
return res.json(); |
| 183 |
}) |
| 184 |
.then(function(sec) { |
| 185 |
var empty = document.getElementById('sections-empty'); |
| 186 |
if (empty) empty.remove(); |
| 187 |
var row = document.createElement('div'); |
| 188 |
row.className = 'section-mgmt-row'; |
| 189 |
row.dataset.id = sec.id; |
| 190 |
row.style.cssText = 'display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0;border-bottom:1px solid var(--border);'; |
| 191 |
row.innerHTML = |
| 192 |
'<span style="flex:1;font-weight:bold;">' + escapeHtml(sec.title) + '</span>' + |
| 193 |
'<span style="font-size:0.8rem;opacity:0.6;">' + (sec.body || '').length + ' chars</span>' + |
| 194 |
'<button type="button" class="btn-secondary section-edit-btn" data-id="' + sec.id + '" style="padding:0.25rem 0.6rem;font-size:0.8rem;">Edit</button>' + |
| 195 |
'<button type="button" class="btn-secondary section-del-btn" data-id="' + sec.id + '" style="padding:0.25rem 0.6rem;font-size:0.8rem;">Delete</button>'; |
| 196 |
document.getElementById('sections-list').appendChild(row); |
| 197 |
attachSectionRowHandlers(row, itemId); |
| 198 |
updateSectionCount(1); |
| 199 |
document.getElementById('new-sec-title').value = ''; |
| 200 |
document.getElementById('new-sec-body').value = ''; |
| 201 |
document.getElementById('section-add-details').removeAttribute('open'); |
| 202 |
}) |
| 203 |
.catch(function(err) { status.textContent = err.message; }) |
| 204 |
.finally(function() { addBtn.disabled = false; }); |
| 205 |
}); |
| 206 |
|
| 207 |
document.getElementById('save-sec-btn').addEventListener('click', function() { |
| 208 |
var id = document.getElementById('edit-sec-id').value; |
| 209 |
var title = document.getElementById('edit-sec-title').value.trim(); |
| 210 |
var body = document.getElementById('edit-sec-body').value; |
| 211 |
var status = document.getElementById('sec-edit-status'); |
| 212 |
if (!title) { status.textContent = 'Title is required'; return; } |
| 213 |
this.disabled = true; |
| 214 |
|
| 215 |
fetch('/api/sections/' + id, { |
| 216 |
method: 'PUT', |
| 217 |
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), |
| 218 |
body: JSON.stringify({ title: title, body: body }) |
| 219 |
}) |
| 220 |
.then(function(res) { |
| 221 |
if (!res.ok) return res.json().then(function(d) { throw new Error(d.error || 'Failed'); }); |
| 222 |
return res.json(); |
| 223 |
}) |
| 224 |
.then(function(sec) { |
| 225 |
var row = document.querySelector('.section-mgmt-row[data-id="' + id + '"]'); |
| 226 |
if (row) { |
| 227 |
row.querySelector('span[style*="font-weight"]').textContent = sec.title; |
| 228 |
row.querySelector('span[style*="opacity"]').textContent = (sec.body || '').length + ' chars'; |
| 229 |
} |
| 230 |
document.getElementById('section-edit-modal').style.display = 'none'; |
| 231 |
}) |
| 232 |
.catch(function(err) { status.textContent = err.message; }) |
| 233 |
.finally(function() { document.getElementById('save-sec-btn').disabled = false; }); |
| 234 |
}); |
| 235 |
|
| 236 |
document.getElementById('cancel-sec-btn').addEventListener('click', function() { |
| 237 |
document.getElementById('section-edit-modal').style.display = 'none'; |
| 238 |
}); |
| 239 |
|
| 240 |
document.querySelectorAll('.section-mgmt-row').forEach(function(row) { |
| 241 |
attachSectionRowHandlers(row, itemId); |
| 242 |
}); |
| 243 |
} |
| 244 |
|
| 245 |
function attachSectionRowHandlers(row, itemId) { |
| 246 |
row.querySelector('.section-del-btn').addEventListener('click', function() { |
| 247 |
var id = this.dataset.id; |
| 248 |
if (!confirm('Delete this section?')) return; |
| 249 |
fetch('/api/sections/' + id, { method: 'DELETE', headers: csrfHeaders() }) |
| 250 |
.then(function(res) { |
| 251 |
if (res.ok) { row.remove(); updateSectionCount(-1); } |
| 252 |
else apiErrorMessage(res, 'Failed to delete').then(function(m) { showToast(m); }); |
| 253 |
}) |
| 254 |
.catch(function() { showToast('Failed to delete'); }); |
| 255 |
}); |
| 256 |
|
| 257 |
row.querySelector('.section-edit-btn').addEventListener('click', function() { |
| 258 |
var id = this.dataset.id; |
| 259 |
var modal = document.getElementById('section-edit-modal'); |
| 260 |
document.getElementById('edit-sec-id').value = id; |
| 261 |
document.getElementById('edit-sec-title').value = row.querySelector('span[style*="font-weight"]').textContent; |
| 262 |
document.getElementById('edit-sec-body').value = ''; |
| 263 |
document.getElementById('sec-edit-status').textContent = 'Loading...'; |
| 264 |
modal.style.display = 'block'; |
| 265 |
|
| 266 |
fetch('/api/items/' + itemId + '/sections') |
| 267 |
.then(function(r) { return r.json(); }) |
| 268 |
.then(function(resp) { |
| 269 |
var sec = resp.data.find(function(s) { return s.id === id; }); |
| 270 |
if (sec) { |
| 271 |
document.getElementById('edit-sec-title').value = sec.title; |
| 272 |
document.getElementById('edit-sec-body').value = sec.body; |
| 273 |
} |
| 274 |
document.getElementById('sec-edit-status').textContent = ''; |
| 275 |
}) |
| 276 |
.catch(function() { document.getElementById('sec-edit-status').textContent = 'Failed to load'; }); |
| 277 |
}); |
| 278 |
} |
| 279 |
|
| 280 |
function updateSectionCount(delta) { |
| 281 |
var el = document.getElementById('section-count'); |
| 282 |
if (el) el.textContent = parseInt(el.textContent) + delta; |
| 283 |
} |
| 284 |
|
| 285 |
|
| 286 |
|
| 287 |
function initTagSearch(itemId) { |
| 288 |
var input = document.getElementById('item-tags'); |
| 289 |
if (!input) return; |
| 290 |
|
| 291 |
var tagSearchTimeout; |
| 292 |
|
| 293 |
window.searchTags = function(q) { |
| 294 |
clearTimeout(tagSearchTimeout); |
| 295 |
var suggestions = document.getElementById('tag-suggestions'); |
| 296 |
if (!q.trim()) { suggestions.style.display = 'none'; return; } |
| 297 |
tagSearchTimeout = setTimeout(function() { |
| 298 |
fetch('/api/tags/search?q=' + encodeURIComponent(q)) |
| 299 |
.then(function(r) { return r.json(); }) |
| 300 |
.then(function(tags) { |
| 301 |
if (!tags.length) { suggestions.style.display = 'none'; return; } |
| 302 |
suggestions.innerHTML = ''; |
| 303 |
tags.forEach(function(t) { |
| 304 |
var div = document.createElement('div'); |
| 305 |
div.style.cssText = 'padding: 0.4rem 0.6rem; cursor: pointer; font-size: 0.85rem;'; |
| 306 |
div.textContent = t.name; |
| 307 |
div.addEventListener('mouseover', function() { this.style.background = 'var(--border)'; }); |
| 308 |
div.addEventListener('mouseout', function() { this.style.background = 'transparent'; }); |
| 309 |
div.addEventListener('click', function() { addTagById(t.id); }); |
| 310 |
suggestions.appendChild(div); |
| 311 |
}); |
| 312 |
suggestions.style.display = 'block'; |
| 313 |
}) |
| 314 |
.catch(function() { suggestions.style.display = 'none'; }); |
| 315 |
}, 200); |
| 316 |
}; |
| 317 |
|
| 318 |
function addTagById(tagId) { |
| 319 |
var form = new FormData(); |
| 320 |
form.append('tag_id', tagId); |
| 321 |
fetch('/api/items/' + itemId + '/tags', { method: 'POST', headers: csrfHeaders(), body: form }) |
| 322 |
.then(function(r) { |
| 323 |
|
| 324 |
|
| 325 |
if (!r.ok) return apiErrorMessage(r, 'Failed to add tag').then(function(m) { throw new Error(m); }); |
| 326 |
return r.text(); |
| 327 |
}) |
| 328 |
.then(function(html) { |
| 329 |
if (html) { |
| 330 |
document.getElementById('tags-container').insertAdjacentHTML('beforeend', html); |
| 331 |
} |
| 332 |
document.getElementById('item-tags').value = ''; |
| 333 |
document.getElementById('tag-suggestions').style.display = 'none'; |
| 334 |
htmx.process(document.getElementById('tags-container')); |
| 335 |
}) |
| 336 |
.catch(function(err) { |
| 337 |
var msg = document.getElementById('item-save-status'); |
| 338 |
if (msg) msg.textContent = err.message || 'Failed to add tag. Please try again.'; |
| 339 |
}); |
| 340 |
} |
| 341 |
|
| 342 |
document.addEventListener('click', function(e) { |
| 343 |
if (!e.target.closest('#item-tags') && !e.target.closest('#tag-suggestions')) { |
| 344 |
var el = document.getElementById('tag-suggestions'); |
| 345 |
if (el) el.style.display = 'none'; |
| 346 |
} |
| 347 |
}); |
| 348 |
} |
| 349 |
|
| 350 |
|
| 351 |
|
| 352 |
function initAiTierToggle() { |
| 353 |
var tierSelect = document.getElementById('ai_tier'); |
| 354 |
var disclosureRow = document.getElementById('ai-disclosure-row'); |
| 355 |
if (tierSelect && disclosureRow) { |
| 356 |
tierSelect.addEventListener('change', function() { |
| 357 |
disclosureRow.classList.toggle('hidden', this.value !== 'assisted'); |
| 358 |
}); |
| 359 |
} |
| 360 |
} |
| 361 |
|
| 362 |
|
| 363 |
|
| 364 |
function initItemImage(itemId) { |
| 365 |
var input = document.getElementById('item-image-input'); |
| 366 |
if (!input) return; |
| 367 |
input.addEventListener('change', function() { |
| 368 |
var file = this.files[0]; |
| 369 |
if (!file) return; |
| 370 |
var status = document.getElementById('item-image-status'); |
| 371 |
status.textContent = 'Uploading...'; |
| 372 |
|
| 373 |
fetch('/api/items/image/presign', { |
| 374 |
method: 'POST', |
| 375 |
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), |
| 376 |
body: JSON.stringify({ item_id: itemId, file_name: file.name, content_type: file.type || 'image/jpeg' }) |
| 377 |
}) |
| 378 |
.then(function(res) { if (!res.ok) throw new Error('Presign failed'); return res.json(); }) |
| 379 |
.then(function(data) { |
| 380 |
var xhr = new XMLHttpRequest(); |
| 381 |
xhr.open('PUT', data.upload_url); |
| 382 |
xhr.setRequestHeader('Content-Type', file.type || 'image/jpeg'); |
| 383 |
if (data.cache_control) xhr.setRequestHeader('Cache-Control', data.cache_control); |
| 384 |
return new Promise(function(resolve, reject) { |
| 385 |
xhr.onload = function() { xhr.status < 300 ? resolve(data.s3_key) : reject(new Error('Upload failed')); }; |
| 386 |
xhr.onerror = function() { reject(new Error('Network error')); }; |
| 387 |
xhr.send(file); |
| 388 |
}); |
| 389 |
}) |
| 390 |
.then(function(s3Key) { |
| 391 |
return fetch('/api/items/image/confirm', { |
| 392 |
method: 'POST', |
| 393 |
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), |
| 394 |
body: JSON.stringify({ item_id: itemId, s3_key: s3Key }) |
| 395 |
}); |
| 396 |
}) |
| 397 |
.then(function(res) { if (!res.ok) throw new Error('Confirm failed'); return res.json(); }) |
| 398 |
.then(function(data) { |
| 399 |
status.textContent = 'Saved.'; |
| 400 |
var preview = document.getElementById('item-image-preview'); |
| 401 |
preview.innerHTML = '<img src="' + data.image_url + '" alt="Item image" style="width:100%;height:100%;object-fit:cover;">'; |
| 402 |
setTimeout(function() { status.textContent = ''; }, 2000); |
| 403 |
}) |
| 404 |
.catch(function(err) { status.textContent = err.message; }); |
| 405 |
}); |
| 406 |
} |
| 407 |
|
| 408 |
|
| 409 |
|
| 410 |
function escapeHtml(s) { |
| 411 |
var d = document.createElement('div'); |
| 412 |
d.textContent = s; |
| 413 |
return d.innerHTML; |
| 414 |
} |
| 415 |
|
| 416 |
|
| 417 |
init(); |
| 418 |
|
| 419 |
|
| 420 |
document.body.addEventListener('htmx:afterSwap', function(e) { |
| 421 |
if (e.detail.target && e.detail.target.id === 'tab-content') { |
| 422 |
setTimeout(init, 0); |
| 423 |
} |
| 424 |
}); |
| 425 |
})(); |
| 426 |
|