| 1 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 46 |
collectionPickerLoad(itemId, picker); |
| 47 |
|
| 48 |
|
| 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 |
|
| 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 |
|
| 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 |
|