Skip to main content

max / makenotwork

7.3 KB · 187 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 var html = '';
87 for (var i = 0; i < cols.length; i++) {
88 var c = cols[i];
89 html += '<label class="collection-picker-item">'
90 + '<input type="checkbox"' + (c.in_collection ? ' checked' : '')
91 + ' onchange="collectionPickerToggle(\'' + c.id + '\',\'' + itemId + '\',this.checked)">'
92 + collectionEscapeHtml(c.title) + '</label>';
93 }
94 list.innerHTML = html;
95 })
96 .catch(function() {
97 list.innerHTML = '<div class="empty-state empty-state--compact" style="color:var(--danger)">Failed to load collections.</div>';
98 });
99 }
100
101 function collectionPickerToggle(collectionId, itemId, add) {
102 fetch('/api/collections/' + collectionId + '/items/' + itemId, {
103 method: add ? 'POST' : 'DELETE',
104 headers: csrfHeaders()
105 }).then(function(r) {
106 if (r.ok) {
107 showToast(add ? 'Added to collection' : 'Removed from collection', 'info');
108 collectionPickerUpdateButtons(itemId);
109 } else {
110 apiErrorMessage(r, add ? 'Failed to add to collection' : 'Failed to remove from collection')
111 .then(function(m) { showToast(m, 'error'); });
112 }
113 }).catch(function() {
114 showToast(add ? 'Failed to add to collection' : 'Failed to remove from collection', 'error');
115 });
116 }
117
118 function collectionPickerCreate(itemId, form) {
119 var title = form.title.value.trim();
120 if (!title) return;
121 var slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
122 var btn = form.querySelector('button');
123 btn.disabled = true;
124
125 fetch('/api/collections', {
126 method: 'POST',
127 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
128 body: JSON.stringify({ title: title, slug: slug, description: '', is_public: false })
129 })
130 .then(function(r) {
131 if (!r.ok) return apiErrorMessage(r, 'Failed to create collection').then(function(m) { throw new Error(m); });
132 return r.json();
133 })
134 .then(function(col) {
135 return fetch('/api/collections/' + col.id + '/items/' + itemId, {
136 method: 'POST',
137 headers: csrfHeaders()
138 }).then(function(r2) {
139 if (!r2.ok) return apiErrorMessage(r2, 'Failed to add item to collection').then(function(m) { throw new Error(m); });
140 });
141 })
142 .then(function() {
143 form.title.value = '';
144 btn.disabled = false;
145 showToast('Created collection and added item', 'info');
146 var picker = document.getElementById('collection-picker-active');
147 if (picker) collectionPickerLoad(itemId, picker);
148 collectionPickerUpdateButtons(itemId);
149 })
150 .catch(function(err) {
151 btn.disabled = false;
152 showToast(err.message || 'Failed to create collection', 'error');
153 });
154 }
155
156 /**
157 * After add/remove, refresh the saved state of any save buttons for this item.
158 * Buttons use data-item-id to identify which item they belong to.
159 */
160 function collectionPickerUpdateButtons(itemId) {
161 fetch('/api/collections/for-item/' + itemId, { headers: csrfHeaders() })
162 .then(function(r) { return r.json(); })
163 .then(function(cols) {
164 var savedCount = 0;
165 for (var i = 0; i < cols.length; i++) {
166 if (cols[i].in_collection) savedCount++;
167 }
168 var buttons = document.querySelectorAll('[data-collection-trigger][data-item-id="' + itemId + '"]');
169 for (var j = 0; j < buttons.length; j++) {
170 var btn = buttons[j];
171 if (btn.dataset.collectionLabel) {
172 // Full label button (item page style)
173 btn.textContent = savedCount > 0
174 ? 'Saved (' + savedCount + ')'
175 : 'Save to collection';
176 btn.classList.toggle('saved', savedCount > 0);
177 }
178 }
179 });
180 }
181
182 function collectionEscapeHtml(s) {
183 var d = document.createElement('div');
184 d.textContent = s;
185 return d.innerHTML;
186 }
187