Skip to main content

max / makenotwork

20.7 KB · 426 lines History Blame Raw
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 // ── Bundles ──
22
23 function initBundles(bundleId) {
24 // "Add existing item" dropdown
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 // Reload the tab to show updated table
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 // "Add Item" row button
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 // Add to table
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 // Enter key creates
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 // Attach handlers to existing rows
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 // ── Sections ──
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 // ── Tag Search ──
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 // Success: server returns an HTML fragment for the new tag.
324 // Failure: server returns JSON `{error: "..."}` via json_error_layer.
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 // ── AI Tier Toggle ──
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 // ── Item Image ──
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 // ── Shared ──
409
410 function escapeHtml(s) {
411 var d = document.createElement('div');
412 d.textContent = s;
413 return d.innerHTML;
414 }
415
416 // Run on initial load
417 init();
418
419 // Re-run when HTMX swaps in tab content
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