Skip to main content

max / makenotwork

27.8 KB · 545 lines History Blame Raw
1 {% include "wizards/partials/step_nav.html" %}
2
3 <div class="wizard-step">
4 <h2 class="subtitle-h2">Content</h2>
5 <p class="step-description">
6 {% if item_type == "text" %}Write your text content below.
7 {% else if item_type == "audio" %}Upload your audio file.
8 {% else if item_type == "video" %}Upload your video file.
9 {% else if item_type == "bundle" %}Select items to include in this bundle.
10 {% else %}Upload your file.
11 {% endif %}
12 </p>
13
14 <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/content"
15 hx-target="#wizard-step" hx-swap="innerHTML"
16 hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing">
17
18 {% if item_type == "text" %}
19 <div class="form-group">
20 <label for="wiz-body">Content</label>
21 <textarea id="wiz-body" name="body" rows="15" class="monospace-input"
22 placeholder="Write your content here... Markdown is supported.">{{ body }}</textarea>
23 <div class="hint">Supports Markdown formatting.</div>
24 </div>
25
26 {% else if item_type == "audio" %}
27 <div class="form-group">
28 <label>Audio File</label>
29 <div class="upload-area" id="audio-upload-area">
30 <p>Drag and drop an audio file here, or click to browse</p>
31 <p class="hint">MP3, WAV, FLAC, OGG, AAC</p>
32 <input type="file" id="audio-file-input" class="sr-only"
33 accept="audio/mpeg,audio/wav,audio/flac,audio/ogg,audio/aac">
34 </div>
35 <div id="audio-upload-status"></div>
36 <button type="button" class="btn-secondary mt-2"
37 onclick="document.getElementById('audio-file-input').click()">Choose File</button>
38 </div>
39
40 {% else if item_type == "video" %}
41 <div class="form-group">
42 <label>Video File</label>
43 <div class="upload-area" id="video-upload-area">
44 <p>Drag and drop a video file here, or click to browse</p>
45 <p class="hint">MP4, WebM, MOV</p>
46 <input type="file" id="video-file-input" class="sr-only"
47 accept="video/mp4,video/webm,video/quicktime">
48 </div>
49 <div id="video-upload-status"></div>
50 <button type="button" class="btn-secondary mt-2"
51 onclick="document.getElementById('video-file-input').click()">Choose File</button>
52 </div>
53 <script>
54 (function() {
55 'use strict';
56 var itemId = '{{ item_id }}';
57 var dropArea = document.getElementById('video-upload-area');
58 var fileInput = document.getElementById('video-file-input');
59 var statusEl = document.getElementById('video-upload-status');
60
61 initDropzone(dropArea, fileInput, function(file) {
62 if (file.type.startsWith('video/') || file.name.match(/\.(mp4|webm|mov)$/i)) {
63 uploadVideo(file);
64 }
65 });
66
67 function uploadVideo(file) {
68 var contentType = file.type || 'video/mp4';
69 statusEl.innerHTML =
70 '<div class="upload-status">' +
71 '<div class="upload-status-row">' +
72 '<span id="video-upload-filename"></span>' +
73 '<span id="video-upload-percent">0%</span></div>' +
74 '<div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded">' +
75 '<div id="video-progress-bar" class="progress-bar progress-bar--highlight"></div></div></div>';
76
77 var uploader = new S3Uploader({
78 filenameEl: document.getElementById('video-upload-filename'),
79 percentEl: document.getElementById('video-upload-percent'),
80 progressBar: document.getElementById('video-progress-bar'),
81 });
82
83 fetch('/api/upload/presign', {
84 method: 'POST',
85 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
86 body: JSON.stringify({ item_id: itemId, file_type: 'video', file_name: file.name, content_type: contentType, file_size_bytes: file.size })
87 })
88 .then(function(res) {
89 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
90 throw new Error(d.error || 'Failed to get upload URL');
91 });
92 return res.json();
93 })
94 .then(function(data) {
95 return uploader.upload(data.upload_url, file, data.s3_key, contentType, data.cache_control);
96 })
97 .then(function(s3Key) {
98 return fetch('/api/upload/confirm', {
99 method: 'POST',
100 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
101 body: JSON.stringify({ item_id: itemId, file_type: 'video', s3_key: s3Key })
102 });
103 })
104 .then(function(res) {
105 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
106 throw new Error(d.error || 'Failed to confirm upload');
107 });
108 return res.json().catch(function() { return {}; });
109 })
110 .then(function(result) {
111 if (result && result.pending_review) {
112 statusEl.innerHTML = '<div class="upload-status-msg is-warning">Upload accepted but held for review — our scanner flagged it.</div>';
113 } else {
114 statusEl.innerHTML = '<div class="upload-status-msg is-success">Upload complete</div>';
115 }
116 })
117 .catch(function(err) {
118 statusEl.innerHTML = '<div class="upload-status-msg is-error">' + (err.message || 'Upload failed') + '</div>';
119 });
120 }
121 })();
122 </script>
123
124 {% else if item_type == "bundle" %}
125 <div class="form-group">
126 <label>Add New Files</label>
127 <div class="hint mb-2">Drop files to create new items and add them to this bundle automatically.</div>
128 <div class="upload-area" id="batch-upload-area">
129 <p>Drag files here, or click to browse</p>
130 <p class="hint">Supports multiple files</p>
131 <input type="file" id="batch-file-input" class="sr-only" multiple>
132 </div>
133 <button type="button" class="btn-secondary mt-2"
134 onclick="document.getElementById('batch-file-input').click()">Choose Files</button>
135
136 <div id="batch-file-queue" class="hidden mt-4">
137 <table class="batch-queue-table">
138 <thead>
139 <tr>
140 <th>File</th>
141 <th>Title</th>
142 <th>Type</th>
143 <th>Status</th>
144 </tr>
145 </thead>
146 <tbody id="batch-file-rows"></tbody>
147 </table>
148 <button type="button" class="btn-primary mt-3" id="batch-upload-btn">Upload All</button>
149 </div>
150 </div>
151
152 <div class="form-group mt-5">
153 <label>Select Items</label>
154 {% if bundleable_items.is_empty() %}
155 <p class="hint">No existing items to select. Upload files above or create items from the project dashboard.</p>
156 {% else %}
157 <div class="hint mb-2">Select items to include. Check "Unlisted" to hide an item from the project page (only accessible through this bundle).</div>
158 {% endif %}
159 <input type="hidden" name="bundle_item_ids" id="bundle-item-ids" value="">
160 <input type="hidden" name="unlisted_item_ids" id="unlisted-item-ids" value="">
161 <div id="bundle-picker">
162 {% for bi in bundleable_items %}
163 <div class="bundle-picker-row">
164 <input type="checkbox" class="bundle-check" data-item-id="{{ bi.id }}"
165 {% if selected_bundle_ids.contains(&bi.id.to_string()) %}checked{% endif %}>
166 <span class="bundle-picker-row-title">{{ bi.title }}</span>
167 <span class="bundle-picker-row-type">{{ bi.item_type }}</span>
168 <label class="bundle-picker-row-unlisted">
169 <input type="checkbox" class="unlisted-check" data-item-id="{{ bi.id }}"
170 {% if unlisted_ids.contains(&bi.id.to_string()) %}checked{% endif %}>
171 Unlisted
172 </label>
173 </div>
174 {% endfor %}
175 </div>
176 </div>
177 <script>
178 (function() {
179 'use strict';
180 var projectId = '{{ project_id }}';
181 var fileQueue = [];
182 var nextIdx = 0;
183
184 var AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a'];
185
186 function getExt(name) {
187 var dot = name.lastIndexOf('.');
188 return dot > 0 ? name.substring(dot + 1).toLowerCase() : '';
189 }
190
191 function titleFromFilename(name) {
192 var dot = name.lastIndexOf('.');
193 var base = dot > 0 ? name.substring(0, dot) : name;
194 return base.replace(/[_\-]/g, ' ').replace(/\s+/g, ' ').trim();
195 }
196
197 function detectType(name) {
198 return AUDIO_EXTS.indexOf(getExt(name)) !== -1 ? 'audio' : 'digital';
199 }
200
201 function addFiles(fileList) {
202 var queueEl = document.getElementById('batch-file-queue');
203 var tbody = document.getElementById('batch-file-rows');
204 for (var i = 0; i < fileList.length; i++) {
205 var f = fileList[i];
206 var idx = nextIdx++;
207 var entry = { file: f, title: titleFromFilename(f.name), type: detectType(f.name), status: 'ready', idx: idx };
208 fileQueue.push(entry);
209
210 var tr = document.createElement('tr');
211 tr.id = 'batch-row-' + idx;
212 tr.innerHTML =
213 '<td class="batch-queue-name" title="' + f.name.replace(/"/g, '&quot;') + '">' + escapeHtml(f.name) + '</td>' +
214 '<td><input type="text" class="batch-title batch-queue-title-input input--xs w-full" data-idx="' + idx + '" value="' + escapeAttr(entry.title) + '"></td>' +
215 '<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>' +
216 '<td class="batch-status" data-idx="' + idx + '">Ready</td>';
217 tbody.appendChild(tr);
218 }
219 queueEl.classList.remove('hidden');
220 }
221
222 function escapeHtml(s) {
223 var d = document.createElement('div');
224 d.textContent = s;
225 return d.innerHTML;
226 }
227
228 function escapeAttr(s) {
229 return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
230 }
231
232 function setStatus(idx, text, variant) {
233 var el = document.querySelector('.batch-status[data-idx="' + idx + '"]');
234 if (el) {
235 el.textContent = text;
236 el.classList.remove('is-success', 'is-error');
237 if (variant) el.classList.add(variant);
238 }
239 }
240
241 function setProgressBar(idx) {
242 var el = document.querySelector('.batch-status[data-idx="' + idx + '"]');
243 if (!el) return {};
244 el.classList.remove('is-success', 'is-error');
245 el.innerHTML =
246 '<div class="upload-progress-row">' +
247 '<div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded">' +
248 '<div class="bp-fill progress-bar progress-bar--highlight"></div></div>' +
249 '<span class="bp-pct upload-progress-pct">0%</span></div>';
250 return { percentEl: el.querySelector('.bp-pct'), progressBar: el.querySelector('.bp-fill') };
251 }
252
253 function appendBundleRow(itemId, title, typeLabel) {
254 var picker = document.getElementById('bundle-picker');
255 var row = document.createElement('div');
256 row.className = 'bundle-picker-row';
257 row.innerHTML =
258 '<input type="checkbox" class="bundle-check" data-item-id="' + itemId + '" checked>' +
259 '<span class="bundle-picker-row-title">' + escapeHtml(title) + '</span>' +
260 '<span class="bundle-picker-row-type">' + escapeHtml(typeLabel) + '</span>' +
261 '<label class="bundle-picker-row-unlisted">' +
262 '<input type="checkbox" class="unlisted-check" data-item-id="' + itemId + '" checked> Unlisted</label>';
263 picker.appendChild(row);
264 }
265
266 async function uploadOne(entry) {
267 var titleInput = document.querySelector('.batch-title[data-idx="' + entry.idx + '"]');
268 var typeSelect = document.querySelector('.batch-type[data-idx="' + entry.idx + '"]');
269 if (titleInput) entry.title = titleInput.value.trim() || entry.title;
270 if (typeSelect) entry.type = typeSelect.value;
271
272 // 1. Create item
273 setStatus(entry.idx, 'Creating...', '');
274 var createRes;
275 try {
276 createRes = await fetch('/api/projects/' + projectId + '/items', {
277 method: 'POST',
278 headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
279 body: new URLSearchParams({ title: entry.title, item_type: entry.type, price_cents: '0' })
280 });
281 } catch (e) {
282 setStatus(entry.idx, 'Error: network', 'is-error');
283 return;
284 }
285 if (!createRes.ok) {
286 var errBody = await createRes.text().catch(function() { return ''; });
287 var errMsg = '';
288 try { errMsg = JSON.parse(errBody).error || errBody; } catch (_) { errMsg = errBody; }
289 setStatus(entry.idx, 'Error: ' + (errMsg || createRes.status), 'is-error');
290 return;
291 }
292 var itemData;
293 try {
294 itemData = await createRes.json();
295 } catch (e) {
296 setStatus(entry.idx, 'Error: bad response', 'is-error');
297 return;
298 }
299 var itemId = itemData.id;
300 entry.itemId = itemId;
301
302 // 2. Upload file
303 var progressEls = setProgressBar(entry.idx);
304 try {
305 if (entry.type === 'audio') {
306 await uploadAudio(entry, itemId, progressEls);
307 } else {
308 await uploadVersion(entry, itemId, progressEls);
309 }
310 } catch (e) {
311 setStatus(entry.idx, e.message || 'Upload failed', 'is-error');
312 return;
313 }
314
315 // 3. Done — only add to picker on success
316 setStatus(entry.idx, 'Done', 'is-success');
317 appendBundleRow(itemId, entry.title, entry.type === 'audio' ? 'Audio' : 'Digital');
318 }
319
320 async function extractError(res, fallback) {
321 var text = await res.text().catch(function() { return ''; });
322 try { return JSON.parse(text).error || fallback; } catch (_) { return text || fallback; }
323 }
324
325 async function uploadAudio(entry, itemId, progressEls) {
326 var file = entry.file;
327 var contentType = file.type || 'audio/mpeg';
328 var presignRes = await fetch('/api/upload/presign', {
329 method: 'POST',
330 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
331 body: JSON.stringify({ item_id: itemId, file_type: 'audio', file_name: file.name, content_type: contentType, file_size_bytes: file.size })
332 });
333 if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed'));
334 var presignData = await presignRes.json();
335
336 var uploader = new S3Uploader(progressEls);
337 await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control);
338
339 var confirmRes = await fetch('/api/upload/confirm', {
340 method: 'POST',
341 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
342 body: JSON.stringify({ item_id: itemId, file_type: 'audio', s3_key: presignData.s3_key })
343 });
344 if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
345 var confirmData = await confirmRes.json().catch(function() { return {}; });
346 if (confirmData && confirmData.pending_review) {
347 showToast('Upload held for review — our scanner flagged it.', 'warning');
348 }
349 }
350
351 async function uploadVersion(entry, itemId, progressEls) {
352 var file = entry.file;
353 var contentType = file.type || 'application/octet-stream';
354 var verRes = await fetch('/api/items/' + itemId + '/versions', {
355 method: 'POST',
356 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
357 body: JSON.stringify({ version_number: '1.0', changelog: null })
358 });
359 if (!verRes.ok) throw new Error(await extractError(verRes, 'Version creation failed'));
360 var verData = await verRes.json();
361 var versionId = verData.id;
362
363 var presignRes = await fetch('/api/versions/' + versionId + '/upload/presign', {
364 method: 'POST',
365 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
366 body: JSON.stringify({ file_name: file.name, content_type: contentType })
367 });
368 if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed'));
369 var presignData = await presignRes.json();
370
371 var uploader = new S3Uploader(progressEls);
372 await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control);
373
374 var confirmRes = await fetch('/api/versions/' + versionId + '/upload/confirm', {
375 method: 'POST',
376 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
377 body: JSON.stringify({ s3_key: presignData.s3_key, file_size_bytes: file.size })
378 });
379 if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
380 var confirmData = await confirmRes.json().catch(function() { return {}; });
381 if (confirmData && confirmData.pending_review) {
382 showToast('Version upload held for review — our scanner flagged it.', 'warning');
383 }
384 }
385
386 async function uploadAll() {
387 var btn = document.getElementById('batch-upload-btn');
388 btn.disabled = true;
389 btn.textContent = 'Uploading...';
390
391 for (var i = 0; i < fileQueue.length; i++) {
392 if (fileQueue[i].status === 'ready') {
393 fileQueue[i].status = 'uploading';
394 await uploadOne(fileQueue[i]);
395 fileQueue[i].status = 'done';
396 }
397 }
398
399 btn.disabled = false;
400 btn.textContent = 'Upload All';
401 }
402
403 // Wire up multi-file dropzone (can't use initDropzone — it only passes one file)
404 var dropArea = document.getElementById('batch-upload-area');
405 var fileInput = document.getElementById('batch-file-input');
406 dropArea.addEventListener('dragover', function(e) {
407 e.preventDefault();
408 dropArea.classList.add('dragover');
409 });
410 dropArea.addEventListener('dragleave', function() {
411 dropArea.classList.remove('dragover');
412 });
413 dropArea.addEventListener('drop', function(e) {
414 e.preventDefault();
415 dropArea.classList.remove('dragover');
416 if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
417 });
418 dropArea.addEventListener('click', function() { fileInput.click(); });
419 fileInput.addEventListener('change', function() {
420 if (this.files.length > 0) addFiles(this.files);
421 this.value = '';
422 });
423
424 document.getElementById('batch-upload-btn').addEventListener('click', uploadAll);
425
426 // Collect bundle IDs on form submit
427 function collectBundleIds() {
428 var ids = [];
429 document.querySelectorAll('.bundle-check:checked').forEach(function(cb) {
430 ids.push(cb.dataset.itemId);
431 });
432 document.getElementById('bundle-item-ids').value = ids.join(',');
433 var uids = [];
434 document.querySelectorAll('.unlisted-check:checked').forEach(function(cb) {
435 uids.push(cb.dataset.itemId);
436 });
437 document.getElementById('unlisted-item-ids').value = uids.join(',');
438 }
439 document.querySelector('form').addEventListener('submit', collectBundleIds);
440 document.querySelector('form').addEventListener('htmx:configRequest', collectBundleIds);
441 })();
442 </script>
443
444 {% else %}
445 <div class="form-group">
446 <label>File</label>
447 <div class="upload-area" id="file-upload-area">
448 <p>Drag and drop your file here, or click to browse</p>
449 <p class="hint">Upload your content file. You can also add versions from the item dashboard.</p>
450 <input type="file" id="file-input" class="sr-only">
451 </div>
452 <div id="file-upload-status"></div>
453 <button type="button" class="btn-secondary mt-2"
454 onclick="document.getElementById('file-input').click()">Choose File</button>
455 </div>
456 <script>
457 (function() {
458 'use strict';
459 var itemId = '{{ item_id }}';
460 var dropArea = document.getElementById('file-upload-area');
461 var fileInput = document.getElementById('file-input');
462 var statusEl = document.getElementById('file-upload-status');
463
464 initDropzone(dropArea, fileInput, function(file) {
465 uploadFile(file);
466 });
467
468 function uploadFile(file) {
469 var contentType = file.type || 'application/octet-stream';
470 statusEl.innerHTML =
471 '<div class="upload-status">' +
472 '<div class="upload-status-row">' +
473 '<span id="file-upload-filename"></span>' +
474 '<span id="file-upload-percent">0%</span></div>' +
475 '<div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded">' +
476 '<div id="file-progress-bar" class="progress-bar progress-bar--highlight"></div></div></div>';
477
478 var uploader = new S3Uploader({
479 filenameEl: document.getElementById('file-upload-filename'),
480 percentEl: document.getElementById('file-upload-percent'),
481 progressBar: document.getElementById('file-progress-bar'),
482 });
483
484 // Create version then upload
485 fetch('/api/items/' + itemId + '/versions', {
486 method: 'POST',
487 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
488 body: JSON.stringify({ version_number: '1.0', changelog: null })
489 })
490 .then(function(res) {
491 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
492 throw new Error(d.error || 'Failed to create version');
493 });
494 return res.json();
495 })
496 .then(function(verData) {
497 return fetch('/api/versions/' + verData.id + '/upload/presign', {
498 method: 'POST',
499 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
500 body: JSON.stringify({ file_name: file.name, content_type: contentType })
501 }).then(function(res) {
502 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
503 throw new Error(d.error || 'Failed to get upload URL');
504 });
505 return res.json();
506 }).then(function(data) {
507 return uploader.upload(data.upload_url, file, data.s3_key, contentType, data.cache_control)
508 .then(function(s3Key) { return { s3Key: s3Key, versionId: verData.id }; });
509 });
510 })
511 .then(function(result) {
512 return fetch('/api/versions/' + result.versionId + '/upload/confirm', {
513 method: 'POST',
514 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
515 body: JSON.stringify({ s3_key: result.s3Key, file_size_bytes: file.size })
516 });
517 })
518 .then(function(res) {
519 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
520 throw new Error(d.error || 'Failed to confirm upload');
521 });
522 statusEl.innerHTML = '<div class="upload-status-msg">Upload complete.</div>';
523 })
524 .catch(function(err) {
525 statusEl.innerHTML = '<div class="upload-status-msg is-error">' + (err.message || 'Upload failed') + '</div>';
526 });
527 }
528 })();
529 </script>
530 {% endif %}
531
532 <div class="wizard-actions">
533 <button type="button" class="btn-secondary"
534 hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/details"
535 hx-target="#wizard-step" hx-swap="innerHTML"
536 hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/details">Back</button>
537 <button type="button" class="btn-secondary"
538 hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing"
539 hx-target="#wizard-step" hx-swap="innerHTML"
540 hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing">Skip</button>
541 <button type="submit" class="btn-primary">Continue</button>
542 </div>
543 </form>
544 </div>
545