Skip to main content

max / makenotwork

7.8 KB · 193 lines History Blame Raw
1 /* Makenotwork, Collections Picker */
2 'use strict';
3
4 /**
5 * Open a collection picker dropdown anchored to the given element.
6 * Works on item page, discover cards, and library rows.
7 *
8 * @param {string} itemId - The item UUID to add/remove from collections.
9 * @param {HTMLElement} anchor - The element to anchor the picker to.
10 * @param {object} [opts] - Options.
11 * @param {string} [opts.position] - 'below' (default) or 'above'.
12 */
13 function openCollectionPicker(itemId, anchor, opts) {
14 opts = opts || {};
15 // Close any existing picker
16 closeCollectionPicker();
17
18 var wrapper = anchor.closest('.collection-picker-anchor') || anchor.parentElement;
19 wrapper.style.position = 'relative';
20
21 var picker = document.createElement('div');
22 picker.id = 'collection-picker-active';
23 picker.className = 'collection-picker';
24 if (opts.position === 'above') {
25 picker.style.bottom = '100%';
26 picker.style.top = 'auto';
27 }
28 picker.innerHTML = '<div class="collection-picker-list"><div class="collection-picker-loading">Loading...</div></div>'
29 + '<div class="collection-picker-create">'
30 + '<form>'
31 + '<input type="text" name="title" placeholder="New collection" required maxlength="100" autocomplete="off">'
32 + '<button class="btn-secondary" type="submit">Create</button>'
33 + '</form></div>';
34
35 wrapper.appendChild(picker);
36 picker.dataset.itemId = itemId;
37
38 // Attach submit handler via addEventListener (avoids inline handler XSS)
39 var form = picker.querySelector('.collection-picker-create form');
40 form.addEventListener('submit', function(e) {
41 e.preventDefault();
42 collectionPickerCreate(itemId, form);
43 });
44
45 // Load collections
46 collectionPickerLoad(itemId, picker);
47
48 // Close on outside click (deferred so this click doesn't close it)
49 setTimeout(function() {
50 document.addEventListener('click', collectionPickerOutsideClick);
51 }, 0);
52 }
53
54 function closeCollectionPicker() {
55 var existing = document.getElementById('collection-picker-active');
56 if (existing) existing.remove();
57 document.removeEventListener('click', collectionPickerOutsideClick);
58 }
59
60 function collectionPickerOutsideClick(e) {
61 var picker = document.getElementById('collection-picker-active');
62 if (picker && !picker.contains(e.target)) {
63 // Check if the click target is a save button (don't close if re-clicking the trigger)
64 if (e.target.closest('[data-collection-trigger]')) return;
65 closeCollectionPicker();
66 }
67 }
68
69 function collectionPickerLoad(itemId, picker) {
70 var list = picker.querySelector('.collection-picker-list');
71 fetch('/api/collections/for-item/' + itemId, { headers: csrfHeaders() })
72 .then(function(r) {
73 if (r.status === 401) {
74 list.innerHTML = '<div class="empty-state empty-state--compact">Sign in to save items to collections.</div>';
75 picker.querySelector('.collection-picker-create').style.display = 'none';
76 return null;
77 }
78 return r.json();
79 })
80 .then(function(cols) {
81 if (cols === null) return;
82 if (cols.length === 0) {
83 list.innerHTML = '<div class="empty-state empty-state--compact">No collections yet. Create one below.</div>';
84 return;
85 }
86 // Build the rows via DOM construction rather than interpolating
87 // c.id / itemId / c.title into an HTML string. Nothing is parsed as
88 // HTML, so there is no escaping to get wrong and no attribute the
89 // ids could break out of.
90 list.innerHTML = '';
91 for (var i = 0; i < cols.length; i++) {
92 var c = cols[i];
93 var label = document.createElement('label');
94 label.className = 'collection-picker-item';
95 var cb = document.createElement('input');
96 cb.type = 'checkbox';
97 cb.checked = !!c.in_collection;
98 (function(collectionId) {
99 cb.addEventListener('change', function() {
100 collectionPickerToggle(collectionId, itemId, this.checked);
101 });
102 })(c.id);
103 label.appendChild(cb);
104 label.appendChild(document.createTextNode(c.title));
105 list.appendChild(label);
106 }
107 })
108 .catch(function() {
109 list.innerHTML = '<div class="empty-state empty-state--compact" style="color:var(--danger)">Failed to load collections.</div>';
110 });
111 }
112
113 function collectionPickerToggle(collectionId, itemId, add) {
114 fetch('/api/collections/' + collectionId + '/items/' + itemId, {
115 method: add ? 'POST' : 'DELETE',
116 headers: csrfHeaders()
117 }).then(function(r) {
118 if (r.ok) {
119 showToast(add ? 'Added to collection' : 'Removed from collection', 'info');
120 collectionPickerUpdateButtons(itemId);
121 } else {
122 apiErrorMessage(r, add ? 'Failed to add to collection' : 'Failed to remove from collection')
123 .then(function(m) { showToast(m, 'error'); });
124 }
125 }).catch(function() {
126 showToast(add ? 'Failed to add to collection' : 'Failed to remove from collection', 'error');
127 });
128 }
129
130 function collectionPickerCreate(itemId, form) {
131 var title = form.title.value.trim();
132 if (!title) return;
133 var slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
134 var btn = form.querySelector('button');
135 btn.disabled = true;
136
137 fetch('/api/collections', {
138 method: 'POST',
139 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
140 body: JSON.stringify({ title: title, slug: slug, description: '', is_public: false })
141 })
142 .then(function(r) {
143 if (!r.ok) return apiErrorMessage(r, 'Failed to create collection').then(function(m) { throw new Error(m); });
144 return r.json();
145 })
146 .then(function(col) {
147 return fetch('/api/collections/' + col.id + '/items/' + itemId, {
148 method: 'POST',
149 headers: csrfHeaders()
150 }).then(function(r2) {
151 if (!r2.ok) return apiErrorMessage(r2, 'Failed to add item to collection').then(function(m) { throw new Error(m); });
152 });
153 })
154 .then(function() {
155 form.title.value = '';
156 btn.disabled = false;
157 showToast('Created collection and added item', 'info');
158 var picker = document.getElementById('collection-picker-active');
159 if (picker) collectionPickerLoad(itemId, picker);
160 collectionPickerUpdateButtons(itemId);
161 })
162 .catch(function(err) {
163 btn.disabled = false;
164 showToast(err.message || 'Failed to create collection', 'error');
165 });
166 }
167
168 /**
169 * After add/remove, refresh the saved state of any save buttons for this item.
170 * Buttons use data-item-id to identify which item they belong to.
171 */
172 function collectionPickerUpdateButtons(itemId) {
173 fetch('/api/collections/for-item/' + itemId, { headers: csrfHeaders() })
174 .then(function(r) { return r.json(); })
175 .then(function(cols) {
176 var savedCount = 0;
177 for (var i = 0; i < cols.length; i++) {
178 if (cols[i].in_collection) savedCount++;
179 }
180 var buttons = document.querySelectorAll('[data-collection-trigger][data-item-id="' + itemId + '"]');
181 for (var j = 0; j < buttons.length; j++) {
182 var btn = buttons[j];
183 if (btn.dataset.collectionLabel) {
184 // Full label button (item page style)
185 btn.textContent = savedCount > 0
186 ? 'Saved (' + savedCount + ')'
187 : 'Save to collection';
188 btn.classList.toggle('saved', savedCount > 0);
189 }
190 }
191 });
192 }
193