Skip to main content

max / makenotwork

7.2 KB · 181 lines History Blame Raw
1 /* Gallery manager — wizard UI for item/project image galleries (launchplan S.1).
2 *
3 * One reusable initializer drives both the item-edit and project-settings
4 * surfaces. It talks to the /api/gallery/* endpoints, reusing the same
5 * presign -> PUT -> confirm pattern as the single cover uploader (no new
6 * upload mechanism). Markup contract (all ids passed in opts):
7 *
8 * opts.targetType "item" | "project"
9 * opts.targetId uuid of the item/project
10 * opts.listId container that holds the thumbnail tiles
11 * opts.inputId <input type="file" multiple accept="image/*">
12 * opts.statusId inline status text element
13 * opts.max optional cap (defaults to 8, mirrors MAX_GALLERY_IMAGES)
14 */
15 function initGalleryManager(opts) {
16 var list = document.getElementById(opts.listId);
17 var input = document.getElementById(opts.inputId);
18 var status = document.getElementById(opts.statusId);
19 if (!list || !input) return;
20 var max = opts.max || 8;
21 var items = []; // [{id, image_url, alt}]
22
23 function setStatus(msg) { if (status) status.textContent = msg || ''; }
24
25 function render() {
26 list.innerHTML = '';
27 items.forEach(function(it, idx) {
28 var tile = document.createElement('div');
29 tile.className = 'gallery-tile';
30 tile.dataset.id = it.id;
31
32 var img = document.createElement('img');
33 img.className = 'gallery-tile-img';
34 img.src = it.image_url;
35 img.alt = it.alt || '';
36 tile.appendChild(img);
37
38 var controls = document.createElement('div');
39 controls.className = 'gallery-tile-controls';
40
41 var left = mkBtn('', 'Move left', idx === 0, function() { move(idx, idx - 1); });
42 var right = mkBtn('', 'Move right', idx === items.length - 1, function() { move(idx, idx + 1); });
43 var del = mkBtn('×', 'Remove', false, function() { remove(it.id); });
44 del.classList.add('gallery-tile-delete');
45 controls.appendChild(left);
46 controls.appendChild(right);
47 controls.appendChild(del);
48 tile.appendChild(controls);
49
50 list.appendChild(tile);
51 });
52 setStatus(items.length + ' / ' + max + ' images');
53 }
54
55 function mkBtn(label, title, disabled, onclick) {
56 var b = document.createElement('button');
57 b.type = 'button';
58 b.className = 'gallery-tile-btn';
59 b.textContent = label;
60 b.title = title;
61 b.setAttribute('aria-label', title);
62 b.disabled = disabled;
63 b.onclick = onclick;
64 return b;
65 }
66
67 function load() {
68 fetch('/api/gallery/list/' + opts.targetType + '/' + opts.targetId, { headers: csrfHeaders() })
69 .then(function(res) { return res.ok ? res.json() : []; })
70 .then(function(data) { items = data || []; render(); })
71 .catch(function() { /* leave empty */ });
72 }
73
74 function move(from, to) {
75 if (to < 0 || to >= items.length) return;
76 var moved = items.splice(from, 1)[0];
77 items.splice(to, 0, moved);
78 render();
79 fetch('/api/gallery/reorder', {
80 method: 'POST',
81 headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
82 body: JSON.stringify({
83 target_type: opts.targetType,
84 target_id: opts.targetId,
85 ordered_ids: items.map(function(i) { return i.id; })
86 })
87 }).catch(function() { setStatus('Reorder failed; reload to retry.'); });
88 }
89
90 function remove(id) {
91 fetch('/api/gallery/image/' + opts.targetType + '/' + id, {
92 method: 'DELETE',
93 headers: csrfHeaders()
94 })
95 .then(function(res) {
96 if (!res.ok) throw new Error('Delete failed');
97 items = items.filter(function(i) { return i.id !== id; });
98 render();
99 })
100 .catch(function() { setStatus('Could not remove image.'); });
101 }
102
103 // Upload one file through presign -> PUT -> confirm; resolves to the new row.
104 function uploadOne(file) {
105 return fetch('/api/gallery/presign', {
106 method: 'POST',
107 headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
108 body: JSON.stringify({
109 target_type: opts.targetType,
110 target_id: opts.targetId,
111 file_name: file.name,
112 content_type: file.type || 'image/jpeg'
113 })
114 })
115 .then(function(res) {
116 if (!res.ok) return res.text().then(function(b) {
117 try { throw new Error(JSON.parse(b).error || 'Upload could not start'); }
118 catch (e) { throw new Error(e.message || 'Upload could not start'); }
119 });
120 return res.json();
121 })
122 .then(function(data) {
123 return new Promise(function(resolve, reject) {
124 var xhr = new XMLHttpRequest();
125 xhr.open('PUT', data.upload_url);
126 xhr.setRequestHeader('Content-Type', file.type || 'image/jpeg');
127 if (data.cache_control) xhr.setRequestHeader('Cache-Control', data.cache_control);
128 xhr.onload = function() { xhr.status < 300 ? resolve(data.s3_key) : reject(new Error('Upload failed')); };
129 xhr.onerror = function() { reject(new Error('Network error')); };
130 xhr.send(file);
131 });
132 })
133 .then(function(s3Key) {
134 return fetch('/api/gallery/confirm', {
135 method: 'POST',
136 headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
137 body: JSON.stringify({
138 target_type: opts.targetType,
139 target_id: opts.targetId,
140 s3_key: s3Key,
141 alt: ''
142 })
143 });
144 })
145 .then(function(res) {
146 if (!res.ok) return res.text().then(function(b) {
147 try { throw new Error(JSON.parse(b).error || 'Confirm failed'); }
148 catch (e) { throw new Error(e.message || 'Confirm failed'); }
149 });
150 return res.json();
151 })
152 .then(function(data) {
153 items.push({ id: data.id, image_url: data.image_url, alt: data.alt || '' });
154 render();
155 });
156 }
157
158 // Upload selected files one at a time (sequential keeps positions stable and
159 // avoids hammering the storage-cap check concurrently).
160 input.addEventListener('change', function() {
161 var files = Array.prototype.slice.call(this.files || []);
162 this.value = '';
163 if (!files.length) return;
164 input.disabled = true;
165 setStatus('Uploading...');
166 var chain = Promise.resolve();
167 files.forEach(function(file) {
168 chain = chain.then(function() {
169 if (items.length >= max) return;
170 return uploadOne(file).catch(function(err) { setStatus(err.message || 'Upload failed.'); });
171 });
172 });
173 chain.then(function() {
174 input.disabled = false;
175 if (items.length >= max) setStatus('Gallery full (' + max + ' images).');
176 });
177 });
178
179 load();
180 }
181