Skip to main content

max / makenotwork

Extract item-upload.js and blog-editor.js to static files item-upload.js (~220 lines): Merge audio and version upload scripts from item_audio_upload.html and item_version_upload.html. Uses data-item-id attribute and htmx:afterSwap for tab re-initialization. blog-editor.js (~100 lines): Extract from dashboard-blog-editor.html. Uses data-project-id, data-project-slug, data-post-id attributes. Full page, no HTMX re-init needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-05 19:29 UTC
Commit: ed694de6a49669ade973f84293f8e6fc4b6913fb
Parent: 76916c6
7 files changed, +390 insertions, -369 deletions
@@ -37,8 +37,8 @@ Dashboard restructure complete (Phases 1-6 in todo_done.md). Tab layout: Project
37 37 - [x] Remove "Loading..." placeholder text from 2FA and passkey sections (empty until revealed)
38 38 - [x] Add static JS convention to CONTRIBUTING.md — data-attribute bridges, IIFE pattern, htmx:afterSwap re-init, CLAUDE.md updated
39 39 - [x] Extract item-details.js (~350 lines) — bundle, section, tag management from item_details.html. Uses htmx:afterSwap for re-init on tab swap
40 - - [ ] Extract item-upload.js (~220 lines) — merge audio + version upload from partials. Same re-init pattern
41 - - [ ] Extract blog-editor.js (~100 lines) — from dashboard-blog-editor.html. Full page, no re-init needed
40 + - [x] Extract item-upload.js (~220 lines) — audio + version upload from partials. htmx:afterSwap re-init
41 + - [x] Extract blog-editor.js (~100 lines) — from dashboard-blog-editor.html. Data attributes for project/post IDs
42 42 - [ ] (Deferred) Extract audio-player.js (~450 lines) — complex state machine, full page load only, low priority
43 43
44 44 #### Discoverability
@@ -0,0 +1,118 @@
1 + /**
2 + * Blog post editor: save draft, publish, auto-save.
3 + *
4 + * Full page (not HTMX partial), no re-init needed.
5 + * Reads project/post data from data attributes on #blog-editor.
6 + * Depends on: mnw.js (csrfHeaders).
7 + */
8 + (function() {
9 + var editor = document.getElementById('blog-editor');
10 + if (!editor) return;
11 +
12 + var projectId = editor.dataset.projectId;
13 + var projectSlug = editor.dataset.projectSlug;
14 + var editingPostId = editor.dataset.postId || null;
15 + var blogAutoSaveTimer = null;
16 + var postStatus = document.getElementById('post-status');
17 +
18 + function getFields() {
19 + return {
20 + title: document.getElementById('post-title').value.trim(),
21 + slug: document.getElementById('post-slug').value.trim(),
22 + body: document.getElementById('post-body').value
23 + };
24 + }
25 +
26 + function goBack() {
27 + window.location.href = '/dashboard/project/' + projectSlug;
28 + }
29 +
30 + function saveBlogPost(publish) {
31 + var f = getFields();
32 + if (!f.title) {
33 + postStatus.innerHTML = '<span style="color: var(--danger);">Title is required</span>';
34 + return;
35 + }
36 + var payload = { title: f.title, body_markdown: f.body, is_published: publish };
37 + if (f.slug) payload.slug = f.slug;
38 +
39 + fetch('/api/projects/' + projectId + '/blog', {
40 + method: 'POST',
41 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
42 + body: JSON.stringify(payload)
43 + })
44 + .then(function(res) {
45 + if (!res.ok) return res.json().then(function(e) { throw new Error(e.message || 'Failed to create post'); });
46 + return res.json();
47 + })
48 + .then(function() { goBack(); })
49 + .catch(function(err) {
50 + postStatus.style.color = 'var(--danger)';
51 + postStatus.textContent = err.message;
52 + });
53 + }
54 +
55 + function updateBlogPost(postId, publish) {
56 + var f = getFields();
57 + if (!f.title) {
58 + postStatus.innerHTML = '<span style="color: var(--danger);">Title is required</span>';
59 + return;
60 + }
61 + if (!f.slug) {
62 + postStatus.innerHTML = '<span style="color: var(--danger);">Slug is required</span>';
63 + return;
64 + }
65 + fetch('/api/blog/' + postId, {
66 + method: 'PUT',
67 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
68 + body: JSON.stringify({ title: f.title, slug: f.slug, body_markdown: f.body, is_published: publish })
69 + })
70 + .then(function(res) {
71 + if (!res.ok) return res.json().then(function(e) { throw new Error(e.error || 'Failed to update post'); });
72 + return res.json();
73 + })
74 + .then(function() { goBack(); })
75 + .catch(function(err) {
76 + postStatus.style.color = 'var(--danger)';
77 + postStatus.textContent = err.message;
78 + });
79 + }
80 +
81 + document.getElementById('save-draft-btn').addEventListener('click', function() {
82 + if (editingPostId) updateBlogPost(editingPostId, false);
83 + else saveBlogPost(false);
84 + });
85 + document.getElementById('publish-btn').addEventListener('click', function() {
86 + if (editingPostId) updateBlogPost(editingPostId, true);
87 + else saveBlogPost(true);
88 + });
89 +
90 + // Auto-save for edit mode (30s debounce)
91 + if (editingPostId) {
92 + ['post-title', 'post-slug', 'post-body'].forEach(function(id) {
93 + document.getElementById(id).addEventListener('input', function() {
94 + clearTimeout(blogAutoSaveTimer);
95 + blogAutoSaveTimer = setTimeout(function() {
96 + var f = getFields();
97 + if (!f.title || !f.slug) return;
98 + postStatus.innerHTML = '<span style="opacity: 0.5;">Saving...</span>';
99 + fetch('/api/blog/' + editingPostId, {
100 + method: 'PUT',
101 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
102 + body: JSON.stringify({ title: f.title, slug: f.slug, body_markdown: f.body, is_published: false })
103 + })
104 + .then(function(res) {
105 + if (!res.ok) throw new Error('Auto-save failed');
106 + postStatus.innerHTML = '<span style="color: var(--text-muted);">Auto-saved</span>';
107 + setTimeout(function() {
108 + if (postStatus.textContent === 'Auto-saved') postStatus.innerHTML = '';
109 + }, 3000);
110 + })
111 + .catch(function() {
112 + postStatus.innerHTML = '<span style="color: var(--danger);">Auto-save failed</span>';
113 + });
114 + }, 30000);
115 + });
116 + });
117 + }
118 + })();
@@ -0,0 +1,263 @@
1 + /**
2 + * Item upload flows: audio file upload and version file upload.
3 + *
4 + * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap.
5 + * Reads item ID from data-item-id on the container element.
6 + * Depends on: upload.js (S3Uploader, initDropzone), mnw.js (csrfHeaders, showToast).
7 + */
8 + (function() {
9 + function init() {
10 + initAudioUpload();
11 + initVersionUpload();
12 + }
13 +
14 + // ── Audio Upload ──
15 +
16 + function initAudioUpload() {
17 + var container = document.getElementById('audio-upload');
18 + if (!container) return;
19 + var itemId = container.dataset.itemId;
20 + if (!itemId) return;
21 +
22 + var uploader = new S3Uploader({
23 + filenameEl: document.getElementById('upload-filename'),
24 + percentEl: document.getElementById('upload-percent'),
25 + progressBar: document.getElementById('progress-bar'),
26 + });
27 +
28 + initDropzone(
29 + document.getElementById('audio-dropzone'),
30 + document.getElementById('audio-file-input'),
31 + function(file) {
32 + if (file.type.startsWith('audio/') || file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i)) {
33 + uploadAudio(file);
34 + }
35 + }
36 + );
37 +
38 + var replaceBtn = document.getElementById('replace-audio-btn');
39 + if (replaceBtn) {
40 + replaceBtn.addEventListener('click', function() {
41 + var cur = document.getElementById('current-audio');
42 + if (cur) cur.classList.add('hidden');
43 + document.getElementById('upload-area').classList.remove('hidden');
44 + resetUpload();
45 + });
46 + }
47 +
48 + document.getElementById('cancel-upload-btn').addEventListener('click', function() {
49 + uploader.cancel();
50 + resetUpload();
51 + });
52 +
53 + document.getElementById('retry-upload-btn').addEventListener('click', resetUpload);
54 +
55 + function resetUpload() {
56 + document.getElementById('audio-dropzone').classList.remove('hidden');
57 + document.getElementById('upload-progress').classList.add('hidden');
58 + document.getElementById('upload-success').classList.add('hidden');
59 + document.getElementById('upload-error').classList.add('hidden');
60 + document.getElementById('audio-file-input').value = '';
61 + }
62 +
63 + function uploadAudio(file) {
64 + document.getElementById('audio-dropzone').classList.add('hidden');
65 + document.getElementById('upload-progress').classList.remove('hidden');
66 +
67 + fetch('/api/upload/presign', {
68 + method: 'POST',
69 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
70 + body: JSON.stringify({
71 + item_id: itemId,
72 + file_type: 'audio',
73 + file_name: file.name,
74 + content_type: file.type || 'audio/mpeg'
75 + })
76 + })
77 + .then(function(res) {
78 + if (!res.ok) throw new Error('Failed to get upload URL');
79 + return res.json();
80 + })
81 + .then(function(data) {
82 + return uploader.upload(data.upload_url, file, data.s3_key, 'audio/mpeg', data.cache_control);
83 + })
84 + .then(function(s3Key) {
85 + return fetch('/api/upload/confirm', {
86 + method: 'POST',
87 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
88 + body: JSON.stringify({
89 + item_id: itemId,
90 + file_type: 'audio',
91 + s3_key: s3Key
92 + })
93 + });
94 + })
95 + .then(function(res) {
96 + if (!res.ok) throw new Error('Failed to confirm upload');
97 + document.getElementById('upload-progress').classList.add('hidden');
98 + document.getElementById('upload-success').classList.remove('hidden');
99 + setTimeout(function() { window.location.href = '/dashboard/item/' + itemId; }, 1500);
100 + })
101 + .catch(function(err) {
102 + document.getElementById('upload-progress').classList.add('hidden');
103 + document.getElementById('upload-error').classList.remove('hidden');
104 + document.getElementById('error-message').textContent = err.message || 'Upload failed';
105 + });
106 + }
107 + }
108 +
109 + // ── Version Upload ──
110 +
111 + function initVersionUpload() {
112 + var container = document.getElementById('version-upload');
113 + if (!container) return;
114 + var itemId = container.dataset.itemId;
115 + if (!itemId) return;
116 +
117 + var selectedFile = null;
118 + var targetVersionId = null;
119 +
120 + var uploader = new S3Uploader({
121 + filenameEl: document.getElementById('version-upload-filename'),
122 + percentEl: document.getElementById('version-upload-percent'),
123 + progressBar: document.getElementById('version-progress-bar'),
124 + });
125 +
126 + var versionDropzone = document.getElementById('version-dropzone');
127 + var versionFileInput = document.getElementById('version-file-input');
128 +
129 + function onNewVersionFile(file) {
130 + selectedFile = file;
131 + versionDropzone.querySelector('.upload-text').textContent = file.name;
132 + }
133 +
134 + initDropzone(versionDropzone, versionFileInput, onNewVersionFile);
135 +
136 + var existingDropzone = document.getElementById('existing-version-dropzone');
137 + var existingFileInput = document.getElementById('existing-version-file-input');
138 +
139 + initDropzone(existingDropzone, existingFileInput, function(file) {
140 + if (targetVersionId) uploadVersionFile(targetVersionId, file);
141 + });
142 +
143 + document.getElementById('create-version-btn').addEventListener('click', function() {
144 + var versionNumber = document.getElementById('new-version-number').value.trim();
145 + var changelog = document.getElementById('version-changelog').value.trim();
146 +
147 + if (!versionNumber) { showToast('Please enter a version number.'); return; }
148 + if (!selectedFile) { showToast('Please select a file to upload.'); return; }
149 +
150 + fetch('/api/items/' + itemId + '/versions', {
151 + method: 'POST',
152 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
153 + body: JSON.stringify({
154 + version_number: versionNumber,
155 + changelog: changelog || null
156 + })
157 + })
158 + .then(function(res) {
159 + if (!res.ok) throw new Error('Failed to create version');
160 + return res.json();
161 + })
162 + .then(function(data) { uploadVersionFile(data.id, selectedFile); })
163 + .catch(function(err) { showVersionError(err.message || 'Failed to create version'); });
164 + });
165 +
166 + document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) {
167 + btn.addEventListener('click', function() {
168 + targetVersionId = btn.dataset.versionId;
169 + document.getElementById('new-version-form').classList.add('hidden');
170 + document.getElementById('existing-version-upload').classList.remove('hidden');
171 + });
172 + });
173 +
174 + document.getElementById('cancel-existing-upload-btn').addEventListener('click', function() {
175 + targetVersionId = null;
176 + document.getElementById('existing-version-upload').classList.add('hidden');
177 + document.getElementById('new-version-form').classList.remove('hidden');
178 + });
179 +
180 + document.querySelectorAll('.download-version-btn').forEach(function(btn) {
181 + btn.addEventListener('click', function() {
182 + fetch('/api/versions/' + btn.dataset.versionId + '/download')
183 + .then(function(res) {
184 + if (!res.ok) throw new Error('Failed to get download URL');
185 + return res.json();
186 + })
187 + .then(function(data) { window.location.href = data.download_url; })
188 + .catch(function(err) { showToast(err.message); });
189 + });
190 + });
191 +
192 + document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
193 + uploader.cancel();
194 + resetVersionUpload();
195 + });
196 +
197 + document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload);
198 +
199 + function uploadVersionFile(versionId, file) {
200 + versionDropzone.classList.add('hidden');
201 + document.getElementById('create-version-btn').classList.add('hidden');
202 + document.getElementById('existing-version-upload').classList.add('hidden');
203 + document.getElementById('version-upload-progress').classList.remove('hidden');
204 +
205 + fetch('/api/versions/' + versionId + '/upload/presign', {
206 + method: 'POST',
207 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
208 + body: JSON.stringify({
209 + file_name: file.name,
210 + content_type: file.type || 'application/octet-stream'
211 + })
212 + })
213 + .then(function(res) {
214 + if (!res.ok) throw new Error('Failed to get upload URL');
215 + return res.json();
216 + })
217 + .then(function(data) {
218 + return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control);
219 + })
220 + .then(function(s3Key) {
221 + return fetch('/api/versions/' + versionId + '/upload/confirm', {
222 + method: 'POST',
223 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
224 + body: JSON.stringify({ s3_key: s3Key, file_size_bytes: file.size })
225 + });
226 + })
227 + .then(function(res) {
228 + if (!res.ok) throw new Error('Failed to confirm upload');
229 + document.getElementById('version-upload-progress').classList.add('hidden');
230 + document.getElementById('version-upload-success').classList.remove('hidden');
231 + setTimeout(function() { window.location.href = '/dashboard/item/' + itemId; }, 1500);
232 + })
233 + .catch(function(err) { showVersionError(err.message || 'Upload failed'); });
234 + }
235 +
236 + function showVersionError(message) {
237 + document.getElementById('version-upload-progress').classList.add('hidden');
238 + document.getElementById('version-upload-error').classList.remove('hidden');
239 + document.getElementById('version-error-message').textContent = message;
240 + }
241 +
242 + function resetVersionUpload() {
243 + versionDropzone.classList.remove('hidden');
244 + document.getElementById('create-version-btn').classList.remove('hidden');
245 + document.getElementById('version-upload-progress').classList.add('hidden');
246 + document.getElementById('version-upload-success').classList.add('hidden');
247 + document.getElementById('version-upload-error').classList.add('hidden');
248 + versionFileInput.value = '';
249 + selectedFile = null;
250 + versionDropzone.querySelector('.upload-text').textContent = 'Drop file here or click to upload';
251 + }
252 + }
253 +
254 + // Run on initial load
255 + init();
256 +
257 + // Re-run when HTMX swaps in upload tabs
258 + document.body.addEventListener('htmx:afterSwap', function(e) {
259 + if (e.detail.target.id === 'tab-content') {
260 + init();
261 + }
262 + });
263 + })();
@@ -25,7 +25,7 @@
25 25
26 26 <h1>{% if editing %}Edit Post{% else %}New Blog Post{% endif %}<span class="dot">.</span></h1>
27 27
28 - <div class="editor-form">
28 + <div class="editor-form" id="blog-editor" data-project-id="{{ project_id }}" data-project-slug="{{ project_slug }}"{% if editing %} data-post-id="{{ post_id }}"{% endif %}>
29 29 <div class="form-group">
30 30 <label for="post-title">Title</label>
31 31 <input type="text" id="post-title" style="width: 100%; padding: 0.5rem;" placeholder="Post title"
@@ -58,114 +58,8 @@
58 58 </div>
59 59 </div>
60 60
61 - <script>
62 - (function() {
63 - var projectId = '{{ project_id }}';
64 - var projectSlug = '{{ project_slug }}';
65 - var editingPostId = {% if editing %}'{{ post_id }}'{% else %}null{% endif %};
66 - var blogAutoSaveTimer = null;
67 - var postStatus = document.getElementById('post-status');
68 -
69 - function getFields() {
70 - return {
71 - title: document.getElementById('post-title').value.trim(),
72 - slug: document.getElementById('post-slug').value.trim(),
73 - body: document.getElementById('post-body').value
74 - };
75 - }
76 -
77 - function goBack() {
78 - window.location.href = '/dashboard/project/' + projectSlug;
79 - }
80 -
81 - function saveBlogPost(publish) {
82 - var f = getFields();
83 - if (!f.title) {
84 - postStatus.innerHTML = '<span style="color: var(--danger);">Title is required</span>';
85 - return;
86 - }
87 - var payload = { title: f.title, body_markdown: f.body, is_published: publish };
88 - if (f.slug) payload.slug = f.slug;
89 -
90 - fetch('/api/projects/' + projectId + '/blog', {
91 - method: 'POST',
92 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
93 - body: JSON.stringify(payload)
94 - })
95 - .then(function(res) {
96 - if (!res.ok) return res.json().then(function(e) { throw new Error(e.message || 'Failed to create post'); });
97 - return res.json();
98 - })
99 - .then(function() { goBack(); })
100 - .catch(function(err) {
101 - postStatus.style.color = 'var(--danger)';
102 - postStatus.textContent = err.message;
103 - });
104 - }
105 -
106 - function updateBlogPost(postId, publish) {
107 - var f = getFields();
108 - if (!f.title) {
109 - postStatus.innerHTML = '<span style="color: var(--danger);">Title is required</span>';
110 - return;
111 - }
112 - if (!f.slug) {
113 - postStatus.innerHTML = '<span style="color: var(--danger);">Slug is required</span>';
114 - return;
115 - }
116 - fetch('/api/blog/' + postId, {
117 - method: 'PUT',
118 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
119 - body: JSON.stringify({ title: f.title, slug: f.slug, body_markdown: f.body, is_published: publish })
120 - })
121 - .then(function(res) {
122 - if (!res.ok) return res.json().then(function(e) { throw new Error(e.error || 'Failed to update post'); });
123 - return res.json();
124 - })
125 - .then(function() { goBack(); })
126 - .catch(function(err) {
127 - postStatus.style.color = 'var(--danger)';
128 - postStatus.textContent = err.message;
129 - });
130 - }
131 -
132 - document.getElementById('save-draft-btn').addEventListener('click', function() {
133 - if (editingPostId) updateBlogPost(editingPostId, false);
134 - else saveBlogPost(false);
135 - });
136 - document.getElementById('publish-btn').addEventListener('click', function() {
137 - if (editingPostId) updateBlogPost(editingPostId, true);
138 - else saveBlogPost(true);
139 - });
61 + {% endblock %}
140 62
141 - // Auto-save for edit mode (30s debounce)
142 - if (editingPostId) {
143 - ['post-title', 'post-slug', 'post-body'].forEach(function(id) {
144 - document.getElementById(id).addEventListener('input', function() {
145 - clearTimeout(blogAutoSaveTimer);
146 - blogAutoSaveTimer = setTimeout(function() {
147 - var f = getFields();
148 - if (!f.title || !f.slug) return;
149 - postStatus.innerHTML = '<span style="opacity: 0.5;">Saving...</span>';
150 - fetch('/api/blog/' + editingPostId, {
151 - method: 'PUT',
152 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
153 - body: JSON.stringify({ title: f.title, slug: f.slug, body_markdown: f.body, is_published: false })
154 - })
155 - .then(function(res) {
156 - if (!res.ok) throw new Error('Auto-save failed');
157 - postStatus.innerHTML = '<span style="color: var(--text-muted);">Auto-saved</span>';
158 - setTimeout(function() {
159 - if (postStatus.textContent === 'Auto-saved') postStatus.innerHTML = '';
160 - }, 3000);
161 - })
162 - .catch(function() {
163 - postStatus.innerHTML = '<span style="color: var(--danger);">Auto-save failed</span>';
164 - });
165 - }, 30000);
166 - });
167 - });
168 - }
169 - })();
170 - </script>
63 + {% block scripts %}
64 + <script src="/static/blog-editor.js"></script>
171 65 {% endblock %}
@@ -135,4 +135,5 @@
135 135
136 136 {% block scripts %}
137 137 <script src="/static/item-details.js"></script>
138 + <script src="/static/item-upload.js"></script>
138 139 {% endblock %}
@@ -1,4 +1,4 @@
1 - <div class="audio-upload" id="audio-upload">
1 + <div class="audio-upload" id="audio-upload" data-item-id="{{ item.id }}">
2 2 {% if let Some(s3_key) = audio_s3_key %}
3 3 <div class="current-audio" id="current-audio">
4 4 <div class="audio-info">
@@ -44,94 +44,3 @@
44 44 </div>
45 45 </div>
46 46 </div>
47 -
48 - <script>
49 - (function() {
50 - var itemId = '{{ item.id }}';
51 - var uploader = new S3Uploader({
52 - filenameEl: document.getElementById('upload-filename'),
53 - percentEl: document.getElementById('upload-percent'),
54 - progressBar: document.getElementById('progress-bar'),
55 - });
56 -
57 - initDropzone(
58 - document.getElementById('audio-dropzone'),
59 - document.getElementById('audio-file-input'),
60 - function(file) {
61 - if (file.type.startsWith('audio/') || file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i)) {
62 - uploadAudio(file);
63 - }
64 - }
65 - );
66 -
67 - var replaceBtn = document.getElementById('replace-audio-btn');
68 - if (replaceBtn) {
69 - replaceBtn.addEventListener('click', function() {
70 - var cur = document.getElementById('current-audio');
71 - if (cur) cur.classList.add('hidden');
72 - document.getElementById('upload-area').classList.remove('hidden');
73 - resetUpload();
74 - });
75 - }
76 -
77 - document.getElementById('cancel-upload-btn').addEventListener('click', function() {
78 - uploader.cancel();
79 - resetUpload();
80 - });
81 -
82 - document.getElementById('retry-upload-btn').addEventListener('click', resetUpload);
83 -
84 - function resetUpload() {
85 - document.getElementById('audio-dropzone').classList.remove('hidden');
86 - document.getElementById('upload-progress').classList.add('hidden');
87 - document.getElementById('upload-success').classList.add('hidden');
88 - document.getElementById('upload-error').classList.add('hidden');
89 - document.getElementById('audio-file-input').value = '';
90 - }
91 -
92 - function uploadAudio(file) {
93 - document.getElementById('audio-dropzone').classList.add('hidden');
94 - document.getElementById('upload-progress').classList.remove('hidden');
95 -
96 - fetch('/api/upload/presign', {
97 - method: 'POST',
98 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
99 - body: JSON.stringify({
100 - item_id: itemId,
101 - file_type: 'audio',
102 - file_name: file.name,
103 - content_type: file.type || 'audio/mpeg'
104 - })
105 - })
106 - .then(function(res) {
107 - if (!res.ok) throw new Error('Failed to get upload URL');
108 - return res.json();
109 - })
110 - .then(function(data) {
111 - return uploader.upload(data.upload_url, file, data.s3_key, 'audio/mpeg', data.cache_control);
112 - })
113 - .then(function(s3Key) {
114 - return fetch('/api/upload/confirm', {
115 - method: 'POST',
116 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
117 - body: JSON.stringify({
118 - item_id: itemId,
119 - file_type: 'audio',
120 - s3_key: s3Key
121 - })
122 - });
123 - })
124 - .then(function(res) {
125 - if (!res.ok) throw new Error('Failed to confirm upload');
126 - document.getElementById('upload-progress').classList.add('hidden');
127 - document.getElementById('upload-success').classList.remove('hidden');
128 - setTimeout(function() { window.location.href = '/dashboard/item/' + itemId; }, 1500);
129 - })
130 - .catch(function(err) {
131 - document.getElementById('upload-progress').classList.add('hidden');
132 - document.getElementById('upload-error').classList.remove('hidden');
133 - document.getElementById('error-message').textContent = err.message || 'Upload failed';
134 - });
135 - }
136 - })();
137 - </script>
@@ -1,4 +1,4 @@
1 - <div class="version-upload" id="version-upload">
1 + <div class="version-upload" id="version-upload" data-item-id="{{ item.id }}">
2 2 <div class="warning-box">
3 3 Version changes are mandatory: Any published change requires a new version number.
4 4 </div>
@@ -98,167 +98,3 @@
98 98 <button class="secondary" id="cancel-existing-upload-btn">Cancel</button>
99 99 </div>
100 100 </div>
101 -
102 - <script>
103 - (function() {
104 - var itemId = '{{ item.id }}';
105 - var selectedFile = null;
106 - var targetVersionId = null;
107 -
108 - var uploader = new S3Uploader({
109 - filenameEl: document.getElementById('version-upload-filename'),
110 - percentEl: document.getElementById('version-upload-percent'),
111 - progressBar: document.getElementById('version-progress-bar'),
112 - });
113 -
114 - // New version dropzone — just selects a file (doesn't start upload)
115 - var versionDropzone = document.getElementById('version-dropzone');
116 - var versionFileInput = document.getElementById('version-file-input');
117 -
118 - function onNewVersionFile(file) {
119 - selectedFile = file;
120 - versionDropzone.querySelector('.upload-text').textContent = file.name;
121 - }
122 -
123 - initDropzone(versionDropzone, versionFileInput, onNewVersionFile);
124 -
125 - // Existing version dropzone — starts upload immediately
126 - var existingDropzone = document.getElementById('existing-version-dropzone');
127 - var existingFileInput = document.getElementById('existing-version-file-input');
128 -
129 - initDropzone(existingDropzone, existingFileInput, function(file) {
130 - if (targetVersionId) uploadVersionFile(targetVersionId, file);
131 - });
132 -
133 - // Create version + upload
134 - document.getElementById('create-version-btn').addEventListener('click', function() {
135 - var versionNumber = document.getElementById('new-version-number').value.trim();
136 - var changelog = document.getElementById('version-changelog').value.trim();
137 -
138 - if (!versionNumber) {
139 - showToast('Please enter a version number.');
140 - return;
141 - }
142 - if (!selectedFile) {
143 - showToast('Please select a file to upload.');
144 - return;
145 - }
146 -
147 - fetch('/api/items/' + itemId + '/versions', {
148 - method: 'POST',
149 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
150 - body: JSON.stringify({
151 - version_number: versionNumber,
152 - changelog: changelog || null
153 - })
154 - })
155 - .then(function(res) {
156 - if (!res.ok) throw new Error('Failed to create version');
157 - return res.json();
158 - })
159 - .then(function(data) {
160 - uploadVersionFile(data.id, selectedFile);
161 - })
162 - .catch(function(err) {
163 - showVersionError(err.message || 'Failed to create version');
164 - });
165 - });
166 -
167 - // Upload to existing version buttons
168 - document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) {
169 - btn.addEventListener('click', function() {
170 - targetVersionId = btn.dataset.versionId;
171 - document.getElementById('new-version-form').classList.add('hidden');
172 - document.getElementById('existing-version-upload').classList.remove('hidden');
173 - });
174 - });
175 -
176 - document.getElementById('cancel-existing-upload-btn').addEventListener('click', function() {
177 - targetVersionId = null;
178 - document.getElementById('existing-version-upload').classList.add('hidden');
179 - document.getElementById('new-version-form').classList.remove('hidden');
180 - });
181 -
182 - // Download version buttons
183 - document.querySelectorAll('.download-version-btn').forEach(function(btn) {
184 - btn.addEventListener('click', function() {
185 - fetch('/api/versions/' + btn.dataset.versionId + '/download')
186 - .then(function(res) {
187 - if (!res.ok) throw new Error('Failed to get download URL');
188 - return res.json();
189 - })
190 - .then(function(data) {
191 - window.location.href = data.download_url;
192 - })
193 - .catch(function(err) { showToast(err.message); });
194 - });
195 - });
196 -
197 - // Cancel / retry
198 - document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
199 - uploader.cancel();
200 - resetVersionUpload();
201 - });
202 -
203 - document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload);
204 -
205 - function uploadVersionFile(versionId, file) {
206 - versionDropzone.classList.add('hidden');
207 - document.getElementById('create-version-btn').classList.add('hidden');
208 - document.getElementById('existing-version-upload').classList.add('hidden');
209 - document.getElementById('version-upload-progress').classList.remove('hidden');
210 -
211 - fetch('/api/versions/' + versionId + '/upload/presign', {
212 - method: 'POST',
213 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
214 - body: JSON.stringify({
215 - file_name: file.name,
216 - content_type: file.type || 'application/octet-stream'
217 - })
218 - })
219 - .then(function(res) {
220 - if (!res.ok) throw new Error('Failed to get upload URL');
221 - return res.json();
222 - })
223 - .then(function(data) {
224 - return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control);
225 - })
226 - .then(function(s3Key) {
227 - return fetch('/api/versions/' + versionId + '/upload/confirm', {
228 - method: 'POST',
229 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
230 - body: JSON.stringify({
231 - s3_key: s3Key,
232 - file_size_bytes: file.size
233 - })
234 - });
235 - })
236 - .then(function(res) {
237 - if (!res.ok) throw new Error('Failed to confirm upload');
238 - document.getElementById('version-upload-progress').classList.add('hidden');
239 - document.getElementById('version-upload-success').classList.remove('hidden');
240 - setTimeout(function() { window.location.href = '/dashboard/item/' + itemId; }, 1500);
241 - })
242 - .catch(function(err) {
243 - showVersionError(err.message || 'Upload failed');
244 - });
245 - }
246 -
247 - function showVersionError(message) {
248 - document.getElementById('version-upload-progress').classList.add('hidden');
249 - document.getElementById('version-upload-error').classList.remove('hidden');
250 - document.getElementById('version-error-message').textContent = message;
251 - }
252 -
253 - function resetVersionUpload() {
254 - versionDropzone.classList.remove('hidden');
255 - document.getElementById('create-version-btn').classList.remove('hidden');
256 - document.getElementById('version-upload-progress').classList.add('hidden');
257 - document.getElementById('version-upload-success').classList.add('hidden');
258 - document.getElementById('version-upload-error').classList.add('hidden');
259 - versionFileInput.value = '';
260 - selectedFile = null;
261 - versionDropzone.querySelector('.upload-text').textContent = 'Drop file here or click to upload';
262 - }
263 - })();
264 - </script>