/* Gallery manager — wizard UI for item/project image galleries (launchplan S.1). * * One reusable initializer drives both the item-edit and project-settings * surfaces. It talks to the /api/gallery/* endpoints, reusing the same * presign -> PUT -> confirm pattern as the single cover uploader (no new * upload mechanism). Markup contract (all ids passed in opts): * * opts.targetType "item" | "project" * opts.targetId uuid of the item/project * opts.listId container that holds the thumbnail tiles * opts.inputId * opts.statusId inline status text element * opts.max optional cap (defaults to 8, mirrors MAX_GALLERY_IMAGES) */ function initGalleryManager(opts) { var list = document.getElementById(opts.listId); var input = document.getElementById(opts.inputId); var status = document.getElementById(opts.statusId); if (!list || !input) return; var max = opts.max || 8; var items = []; // [{id, image_url, alt}] function setStatus(msg) { if (status) status.textContent = msg || ''; } function render() { list.innerHTML = ''; items.forEach(function(it, idx) { var tile = document.createElement('div'); tile.className = 'gallery-tile'; tile.dataset.id = it.id; var img = document.createElement('img'); img.className = 'gallery-tile-img'; img.src = it.image_url; img.alt = it.alt || ''; tile.appendChild(img); var controls = document.createElement('div'); controls.className = 'gallery-tile-controls'; var left = mkBtn('‹', 'Move left', idx === 0, function() { move(idx, idx - 1); }); var right = mkBtn('›', 'Move right', idx === items.length - 1, function() { move(idx, idx + 1); }); var del = mkBtn('×', 'Remove', false, function() { remove(it.id); }); del.classList.add('gallery-tile-delete'); controls.appendChild(left); controls.appendChild(right); controls.appendChild(del); tile.appendChild(controls); list.appendChild(tile); }); setStatus(items.length + ' / ' + max + ' images'); } function mkBtn(label, title, disabled, onclick) { var b = document.createElement('button'); b.type = 'button'; b.className = 'gallery-tile-btn'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title); b.disabled = disabled; b.onclick = onclick; return b; } function load() { fetch('/api/gallery/list/' + opts.targetType + '/' + opts.targetId, { headers: csrfHeaders() }) .then(function(res) { return res.ok ? res.json() : []; }) .then(function(data) { items = data || []; render(); }) .catch(function() { /* leave empty */ }); } function move(from, to) { if (to < 0 || to >= items.length) return; var moved = items.splice(from, 1)[0]; items.splice(to, 0, moved); render(); fetch('/api/gallery/reorder', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), body: JSON.stringify({ target_type: opts.targetType, target_id: opts.targetId, ordered_ids: items.map(function(i) { return i.id; }) }) }).catch(function() { setStatus('Reorder failed; reload to retry.'); }); } function remove(id) { fetch('/api/gallery/image/' + opts.targetType + '/' + id, { method: 'DELETE', headers: csrfHeaders() }) .then(function(res) { if (!res.ok) throw new Error('Delete failed'); items = items.filter(function(i) { return i.id !== id; }); render(); }) .catch(function() { setStatus('Could not remove image.'); }); } // Upload one file through presign -> PUT -> confirm; resolves to the new row. function uploadOne(file) { return fetch('/api/gallery/presign', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), body: JSON.stringify({ target_type: opts.targetType, target_id: opts.targetId, file_name: file.name, content_type: file.type || 'image/jpeg' }) }) .then(function(res) { if (!res.ok) return res.text().then(function(b) { try { throw new Error(JSON.parse(b).error || 'Upload could not start'); } catch (e) { throw new Error(e.message || 'Upload could not start'); } }); return res.json(); }) .then(function(data) { return new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open('PUT', data.upload_url); xhr.setRequestHeader('Content-Type', file.type || 'image/jpeg'); if (data.cache_control) xhr.setRequestHeader('Cache-Control', data.cache_control); xhr.onload = function() { xhr.status < 300 ? resolve(data.s3_key) : reject(new Error('Upload failed')); }; xhr.onerror = function() { reject(new Error('Network error')); }; xhr.send(file); }); }) .then(function(s3Key) { return fetch('/api/gallery/confirm', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), body: JSON.stringify({ target_type: opts.targetType, target_id: opts.targetId, s3_key: s3Key, alt: '' }) }); }) .then(function(res) { if (!res.ok) return res.text().then(function(b) { try { throw new Error(JSON.parse(b).error || 'Confirm failed'); } catch (e) { throw new Error(e.message || 'Confirm failed'); } }); return res.json(); }) .then(function(data) { items.push({ id: data.id, image_url: data.image_url, alt: data.alt || '' }); render(); }); } // Upload selected files one at a time (sequential keeps positions stable and // avoids hammering the storage-cap check concurrently). input.addEventListener('change', function() { var files = Array.prototype.slice.call(this.files || []); this.value = ''; if (!files.length) return; input.disabled = true; setStatus('Uploading...'); var chain = Promise.resolve(); files.forEach(function(file) { chain = chain.then(function() { if (items.length >= max) return; return uploadOne(file).catch(function(err) { setStatus(err.message || 'Upload failed.'); }); }); }); chain.then(function() { input.disabled = false; if (items.length >= max) setStatus('Gallery full (' + max + ' images).'); }); }); load(); }