Skip to main content

max / makenotwork

11.6 KB · 260 lines History Blame Raw
1 (function() {
2 'use strict';
3 var projectId = document.getElementById('wizard-item-content-bundle-cfg').dataset.projectId;
4 var fileQueue = [];
5 var nextIdx = 0;
6
7 var AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a'];
8
9 function getExt(name) {
10 var dot = name.lastIndexOf('.');
11 return dot > 0 ? name.substring(dot + 1).toLowerCase() : '';
12 }
13
14 function titleFromFilename(name) {
15 var dot = name.lastIndexOf('.');
16 var base = dot > 0 ? name.substring(0, dot) : name;
17 return base.replace(/[_\-]/g, ' ').replace(/\s+/g, ' ').trim();
18 }
19
20 function detectType(name) {
21 return AUDIO_EXTS.indexOf(getExt(name)) !== -1 ? 'audio' : 'digital';
22 }
23
24 function addFiles(fileList) {
25 var queueEl = document.getElementById('batch-file-queue');
26 var tbody = document.getElementById('batch-file-rows');
27 for (var i = 0; i < fileList.length; i++) {
28 var f = fileList[i];
29 var idx = nextIdx++;
30 var entry = { file: f, title: titleFromFilename(f.name), type: detectType(f.name), status: 'ready', idx: idx };
31 fileQueue.push(entry);
32
33 var tr = document.createElement('tr');
34 tr.id = 'batch-row-' + idx;
35 tr.innerHTML =
36 '<td class="batch-queue-name" title="' + f.name.replace(/"/g, '&quot;') + '">' + escapeHtml(f.name) + '</td>' +
37 '<td><input type="text" class="batch-title batch-queue-title-input input--xs w-full" data-idx="' + idx + '" value="' + escapeAttr(entry.title) + '"></td>' +
38 '<td><select class="batch-type batch-queue-type-select input--xs" data-idx="' + idx + '"><option value="audio"' + (entry.type === 'audio' ? ' selected' : '') + '>Audio</option><option value="digital"' + (entry.type === 'digital' ? ' selected' : '') + '>Digital</option></select></td>' +
39 '<td class="batch-status" data-idx="' + idx + '">Ready</td>';
40 tbody.appendChild(tr);
41 }
42 queueEl.classList.remove('hidden');
43 }
44
45
46 function escapeAttr(s) {
47 return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
48 }
49
50 function setStatus(idx, text, variant) {
51 var el = document.querySelector('.batch-status[data-idx="' + idx + '"]');
52 if (el) {
53 el.textContent = text;
54 el.classList.remove('is-success', 'is-error');
55 if (variant) el.classList.add(variant);
56 }
57 }
58
59 function setProgressBar(idx) {
60 var el = document.querySelector('.batch-status[data-idx="' + idx + '"]');
61 if (!el) return {};
62 el.classList.remove('is-success', 'is-error');
63 el.innerHTML =
64 '<div class="upload-progress-row">' +
65 '<div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded">' +
66 '<div class="bp-fill progress-bar progress-bar--highlight"></div></div>' +
67 '<span class="bp-pct upload-progress-pct">0%</span></div>';
68 return { percentEl: el.querySelector('.bp-pct'), progressBar: el.querySelector('.bp-fill') };
69 }
70
71 function appendBundleRow(itemId, title, typeLabel) {
72 var picker = document.getElementById('bundle-picker');
73 var row = document.createElement('div');
74 row.className = 'bundle-picker-row';
75 row.innerHTML =
76 '<input type="checkbox" class="bundle-check" data-item-id="' + itemId + '" checked>' +
77 '<span class="bundle-picker-row-title">' + escapeHtml(title) + '</span>' +
78 '<span class="bundle-picker-row-type">' + escapeHtml(typeLabel) + '</span>' +
79 '<label class="bundle-picker-row-unlisted">' +
80 '<input type="checkbox" class="unlisted-check" data-item-id="' + itemId + '" checked> Unlisted</label>';
81 picker.appendChild(row);
82 }
83
84 async function uploadOne(entry) {
85 var titleInput = document.querySelector('.batch-title[data-idx="' + entry.idx + '"]');
86 var typeSelect = document.querySelector('.batch-type[data-idx="' + entry.idx + '"]');
87 if (titleInput) entry.title = titleInput.value.trim() || entry.title;
88 if (typeSelect) entry.type = typeSelect.value;
89
90 // 1. Create item
91 setStatus(entry.idx, 'Creating...', '');
92 var createRes;
93 try {
94 createRes = await fetch('/api/projects/' + projectId + '/items', {
95 method: 'POST',
96 headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
97 body: new URLSearchParams({ title: entry.title, item_type: entry.type, price_cents: '0' })
98 });
99 } catch (e) {
100 setStatus(entry.idx, 'Error: network', 'is-error');
101 return;
102 }
103 if (!createRes.ok) {
104 var errBody = await createRes.text().catch(function() { return ''; });
105 var errMsg = '';
106 try { errMsg = JSON.parse(errBody).error || errBody; } catch (_) { errMsg = errBody; }
107 setStatus(entry.idx, 'Error: ' + (errMsg || createRes.status), 'is-error');
108 return;
109 }
110 var itemData;
111 try {
112 itemData = await createRes.json();
113 } catch (e) {
114 setStatus(entry.idx, 'Error: bad response', 'is-error');
115 return;
116 }
117 var itemId = itemData.id;
118 entry.itemId = itemId;
119
120 // 2. Upload file
121 var progressEls = setProgressBar(entry.idx);
122 try {
123 if (entry.type === 'audio') {
124 await uploadAudio(entry, itemId, progressEls);
125 } else {
126 await uploadVersion(entry, itemId, progressEls);
127 }
128 } catch (e) {
129 setStatus(entry.idx, e.message || 'Upload failed', 'is-error');
130 return;
131 }
132
133 // 3. Done — only add to picker on success
134 setStatus(entry.idx, 'Done', 'is-success');
135 appendBundleRow(itemId, entry.title, entry.type === 'audio' ? 'Audio' : 'Digital');
136 }
137
138 async function extractError(res, fallback) {
139 var text = await res.text().catch(function() { return ''; });
140 try { return JSON.parse(text).error || fallback; } catch (_) { return text || fallback; }
141 }
142
143 async function uploadAudio(entry, itemId, progressEls) {
144 var file = entry.file;
145 var contentType = file.type || 'audio/mpeg';
146 var presignRes = await fetch('/api/upload/presign', {
147 method: 'POST',
148 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
149 body: JSON.stringify({ item_id: itemId, file_type: 'audio', file_name: file.name, content_type: contentType, file_size_bytes: file.size })
150 });
151 if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed'));
152 var presignData = await presignRes.json();
153
154 var uploader = new S3Uploader(progressEls);
155 await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control);
156
157 var confirmRes = await fetch('/api/upload/confirm', {
158 method: 'POST',
159 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
160 body: JSON.stringify({ item_id: itemId, file_type: 'audio', s3_key: presignData.s3_key })
161 });
162 if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
163 var confirmData = await confirmRes.json().catch(function() { return {}; });
164 if (confirmData && confirmData.pending_review) {
165 showToast('Upload held for review — our scanner flagged it.', 'warning');
166 }
167 }
168
169 async function uploadVersion(entry, itemId, progressEls) {
170 var file = entry.file;
171 var contentType = file.type || 'application/octet-stream';
172 var verRes = await fetch('/api/items/' + itemId + '/versions', {
173 method: 'POST',
174 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
175 body: JSON.stringify({ version_number: '1.0', changelog: null })
176 });
177 if (!verRes.ok) throw new Error(await extractError(verRes, 'Version creation failed'));
178 var verData = await verRes.json();
179 var versionId = verData.id;
180
181 var presignRes = await fetch('/api/versions/' + versionId + '/upload/presign', {
182 method: 'POST',
183 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
184 body: JSON.stringify({ file_name: file.name, content_type: contentType })
185 });
186 if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed'));
187 var presignData = await presignRes.json();
188
189 var uploader = new S3Uploader(progressEls);
190 await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control);
191
192 var confirmRes = await fetch('/api/versions/' + versionId + '/upload/confirm', {
193 method: 'POST',
194 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
195 body: JSON.stringify({ s3_key: presignData.s3_key, file_size_bytes: file.size })
196 });
197 if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
198 var confirmData = await confirmRes.json().catch(function() { return {}; });
199 if (confirmData && confirmData.pending_review) {
200 showToast('Version upload held for review — our scanner flagged it.', 'warning');
201 }
202 }
203
204 async function uploadAll() {
205 var btn = document.getElementById('batch-upload-btn');
206 btn.disabled = true;
207 btn.textContent = 'Uploading...';
208
209 for (var i = 0; i < fileQueue.length; i++) {
210 if (fileQueue[i].status === 'ready') {
211 fileQueue[i].status = 'uploading';
212 await uploadOne(fileQueue[i]);
213 fileQueue[i].status = 'done';
214 }
215 }
216
217 btn.disabled = false;
218 btn.textContent = 'Upload All';
219 }
220
221 // Wire up multi-file dropzone (can't use initDropzone — it only passes one file)
222 var dropArea = document.getElementById('batch-upload-area');
223 var fileInput = document.getElementById('batch-file-input');
224 dropArea.addEventListener('dragover', function(e) {
225 e.preventDefault();
226 dropArea.classList.add('dragover');
227 });
228 dropArea.addEventListener('dragleave', function() {
229 dropArea.classList.remove('dragover');
230 });
231 dropArea.addEventListener('drop', function(e) {
232 e.preventDefault();
233 dropArea.classList.remove('dragover');
234 if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
235 });
236 dropArea.addEventListener('click', function() { fileInput.click(); });
237 fileInput.addEventListener('change', function() {
238 if (this.files.length > 0) addFiles(this.files);
239 this.value = '';
240 });
241
242 document.getElementById('batch-upload-btn').addEventListener('click', uploadAll);
243
244 // Collect bundle IDs on form submit
245 function collectBundleIds() {
246 var ids = [];
247 document.querySelectorAll('.bundle-check:checked').forEach(function(cb) {
248 ids.push(cb.dataset.itemId);
249 });
250 document.getElementById('bundle-item-ids').value = ids.join(',');
251 var uids = [];
252 document.querySelectorAll('.unlisted-check:checked').forEach(function(cb) {
253 uids.push(cb.dataset.itemId);
254 });
255 document.getElementById('unlisted-item-ids').value = uids.join(',');
256 }
257 document.querySelector('form').addEventListener('submit', collectBundleIds);
258 document.querySelector('form').addEventListener('htmx:configRequest', collectBundleIds);
259 })();
260