From 87b0d46232870fd6b447305df40a693290f4473d Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Mon, 3 Aug 2026 21:05:12 -0500 Subject: [PATCH] working UGC content upload with multi-upload support --- nonpacks/library/forms.py | 9 - nonpacks/library/tests.py | 117 ++++++++-- nonpacks/library/views.py | 80 +++++-- nonpacks/static/css/style.css | 34 ++- nonpacks/static/js/asset_upload.js | 152 +----------- nonpacks/static/js/create.js | 220 ++---------------- nonpacks/static/js/edit.js | 9 + nonpacks/static/js/tags.js | 12 +- nonpacks/static/js/uploads.js | 214 +++++++++++++++++ nonpacks/static/js/version_upload.js | 8 + .../library/_project_form_fields.html | 26 --- .../templates/library/_project_form_side.html | 17 -- .../library/_version_form_fields.html | 18 -- nonpacks/templates/library/asset_upload.html | 3 +- .../templates/library/project_create.html | 4 +- .../templates/library/project_detail.html | 23 ++ nonpacks/templates/library/project_edit.html | 62 ++++- .../templates/library/version_upload.html | 45 +++- 18 files changed, 563 insertions(+), 490 deletions(-) create mode 100644 nonpacks/static/js/edit.js create mode 100644 nonpacks/static/js/uploads.js create mode 100644 nonpacks/static/js/version_upload.js delete mode 100644 nonpacks/templates/library/_project_form_fields.html delete mode 100644 nonpacks/templates/library/_project_form_side.html delete mode 100644 nonpacks/templates/library/_version_form_fields.html diff --git a/nonpacks/library/forms.py b/nonpacks/library/forms.py index d3d4e6f..b3ec410 100644 --- a/nonpacks/library/forms.py +++ b/nonpacks/library/forms.py @@ -21,7 +21,6 @@ class ProjectForm(forms.Form): 'placeholder': 'Write the description in Markdown…', }), ) - thumbnail = forms.ImageField(required=False) caption = forms.CharField( max_length=128, required=False, @@ -35,19 +34,11 @@ class ProjectForm(forms.Form): class VersionForm(forms.Form): - def __init__(self, *args, required_file=True, **kwargs): - # The create flow uploads the version file out-of-band (temp upload), - # so there the `file` field is optional in the bound form. - super().__init__(*args, **kwargs) - if not required_file: - self.fields['file'].required = False - version_name = forms.CharField( max_length=64, label='Version', widget=forms.TextInput(attrs={'placeholder': 'e.g. 1.0.0'}), ) - file = forms.FileField(label='File') changelog = forms.CharField( required=False, widget=forms.Textarea(attrs={ diff --git a/nonpacks/library/tests.py b/nonpacks/library/tests.py index 4f0424c..96b5744 100644 --- a/nonpacks/library/tests.py +++ b/nonpacks/library/tests.py @@ -194,29 +194,42 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase): def test_upload_version_owner(self): self.gate() self.client.login(username='Alice', password='pw') + self.client.post( + reverse('library:api_upload_temp'), + {'kind': 'version', 'file': SimpleUploadedFile('pack2.zip', make_zip_bytes(), content_type='application/zip')}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) resp = self.client.post( reverse('library:version_upload', args=['test-pack']), - { - 'version_name': '2.0.0', - 'changelog': 'More stuff.', - 'file': SimpleUploadedFile('pack2.zip', make_zip_bytes(), content_type='application/zip'), - }, + {'version_name': '2.0.0', 'changelog': 'More stuff.'}, ) self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack'])) self.assertEqual(self.project.versions.count(), 1) - self.assertEqual(self.project.versions.first().version_name, '2.0.0') - # Newest is latest (ordering). - self.assertTrue(self.project.versions.first().file_id) + version = self.project.versions.first() + self.assertEqual(version.version_name, '2.0.0') + self.assertTrue(version.file_id) + # Adopted file moved under project_/versions/ and temp marked used. + self.assertTrue(version.file.stored_path.startswith('project_')) + self.assertTrue( + TempUpload.objects.filter(user=self.alice, kind='version', status='used').exists() + ) + + def test_upload_version_requires_pending_file(self): + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.post( + reverse('library:version_upload', args=['test-pack']), + {'version_name': '2.0.0'}, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(self.project.versions.count(), 0) def test_upload_version_non_contributor_denied(self): self.gate() self.client.login(username='Bob', password='pw') resp = self.client.post( reverse('library:version_upload', args=['test-pack']), - { - 'version_name': '2.0.0', - 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'), - }, + {'version_name': '2.0.0'}, ) self.assertEqual(resp.status_code, 403) self.assertEqual(self.project.versions.count(), 0) @@ -226,12 +239,14 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase): self.project.sync_creator_tags() self.gate() self.client.login(username='Bob', password='pw') + self.client.post( + reverse('library:api_upload_temp'), + {'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip')}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) resp = self.client.post( reverse('library:version_upload', args=['test-pack']), - { - 'version_name': '2.0.0', - 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'), - }, + {'version_name': '2.0.0'}, ) self.assertEqual(resp.status_code, 302) self.assertEqual(self.project.versions.count(), 1) @@ -317,6 +332,40 @@ class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase): self.assertFalse(Project.objects.filter(pk=self.project.pk).exists() is False) # project intact self.assertTrue(Project.objects.filter(pk=self.project.pk).exists()) + def test_asset_delete_ajax_returns_json(self): + from library.storage import store_file + + index = store_file( + self.alice, 'asset', + SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'), + self.project.pk, subdir='gallery', + ) + asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice) + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.post( + reverse('library:asset_delete', args=['test-pack', asset.pk]), + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + self.assertEqual(resp.status_code, 200) + self.assertJSONEqual(resp.content, {'success': True}) + self.assertEqual(ProjectAsset.objects.count(), 0) + + def test_asset_delete_requires_post(self): + from library.storage import store_file + + index = store_file( + self.alice, 'asset', + SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'), + self.project.pk, subdir='gallery', + ) + asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice) + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.get(reverse('library:asset_delete', args=['test-pack', asset.pk])) + self.assertEqual(resp.status_code, 405) + self.assertEqual(ProjectAsset.objects.count(), 1) + def test_asset_delete_non_contributor_denied(self): from library.storage import store_file @@ -515,6 +564,42 @@ class UGCPermissionTests(UGCMediaTestCase, UGCGatedTestCase): self.project.refresh_from_db() self.assertEqual(self.project.title, 'Renamed Pack') + def test_edit_replaces_thumbnail_via_temp(self): + self.gate() + self.client.login(username='Alice', password='pw') + self.client.post( + reverse('library:api_upload_temp'), + {'kind': 'thumbnail', 'file': SimpleUploadedFile('thumb.png', make_png_bytes(), content_type='image/png')}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + resp = self.client.post( + reverse('library:project_edit', args=['test-pack']), + {'title': 'Test Pack', 'category': 'mod', 'description': 'x'}, + ) + self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack'])) + self.project.refresh_from_db() + self.assertIsNotNone(self.project.thumbnail) + self.assertTrue(self.project.thumbnail.stored_path.startswith('project_')) + self.assertTrue( + TempUpload.objects.filter(user=self.alice, kind='thumbnail', status='used').exists() + ) + + def test_edit_shows_existing_thumbnail_preview(self): + from library.storage import store_file + + index = store_file( + self.alice, 'thumbnail', + SimpleUploadedFile('thumb.png', make_png_bytes(), content_type='image/png'), + self.project.pk, subdir='', + ) + self.project.thumbnail = index + self.project.save() + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.get(reverse('library:project_edit', args=['test-pack'])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, str(index.uuid)) + def test_edit_non_contributor_denied(self): self.gate() self.client.login(username='Bob', password='pw') diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index ba7bb71..dd31102 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -27,7 +27,7 @@ from .models import ( Version, slugify_tag, ) -from .storage import delete_file_index, move_file_index, store_file, store_temp_file +from .storage import delete_file_index, move_file_index, store_temp_file def _open_indexed_file(file_index): @@ -345,11 +345,11 @@ def _form_values(*forms): @login_required def project_create(request): project_form = ProjectForm() - version_form = VersionForm(required_file=False) + version_form = VersionForm() if request.method == 'POST': project_form = ProjectForm(request.POST) - version_form = VersionForm(request.POST, required_file=False) + version_form = VersionForm(request.POST) if project_form.is_valid() and version_form.is_valid(): data = project_form.cleaned_data version_file = _latest_pending(request.user, 'version') @@ -408,7 +408,6 @@ def project_create(request): initial={ k: initial[k] for k in ('version_name', 'changelog') if k in initial }, - required_file=False, ) return render(request, 'library/project_create.html', { @@ -416,6 +415,7 @@ def project_create(request): 'version_form': version_form, 'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)], 'draft_data': _form_values(project_form, version_form), + 'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')), }) @@ -426,19 +426,21 @@ def project_edit(request, slug): return HttpResponseForbidden('You do not have permission to edit this project.') if request.method == 'POST': - form = ProjectForm(request.POST, request.FILES) + form = ProjectForm(request.POST) if form.is_valid(): data = form.cleaned_data project.title = data['title'] project.summary = data['summary'] project.description = data['description'] project.category = data['category'] - thumbnail = data.get('thumbnail') - if thumbnail: + + thumb = _latest_pending(request.user, 'thumbnail') + if thumb is not None: delete_file_index(project.thumbnail) - project.thumbnail = store_file( - request.user, 'thumbnail', thumbnail, project.pk, subdir='', - ) + project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail') + thumb.status = 'used' + thumb.save(update_fields=['status']) + project.save() _apply_tags(project, data['tags'], request.user) project.sync_creator_tags(actor=request.user) @@ -452,9 +454,27 @@ def project_edit(request, slug): 'category': project.category, 'tags': _current_tags(project), }) + + thumb = _latest_pending(request.user, 'thumbnail') + if thumb is not None: + pending_uploads = [_serialize_temp(thumb)] + elif project.thumbnail_id: + pending_uploads = [{ + 'uuid': str(project.thumbnail.uuid), + 'kind': 'thumbnail', + 'filename': project.thumbnail.original_filename, + 'content_type': project.thumbnail.content_type, + 'url': project.thumbnail_url, + 'removable': False, + }] + else: + pending_uploads = [] + return render(request, 'library/project_edit.html', { 'project': project, 'project_form': form, + 'pending_uploads': pending_uploads, + 'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')), }) @@ -485,23 +505,29 @@ def version_upload(request, slug): form = VersionForm() if request.method == 'POST': - form = VersionForm(request.POST, request.FILES) + form = VersionForm(request.POST) if form.is_valid(): - index = store_file( - request.user, 'version', form.cleaned_data['file'], - project.pk, subdir='versions', - ) - Version.objects.create( - project=project, - version_name=form.cleaned_data['version_name'], - file=index, - changelog=form.cleaned_data['changelog'], - ) - messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.') - return redirect('library:project_detail', slug=project.slug) + version_file = _latest_pending(request.user, 'version') + if version_file is None: + form.add_error('version_name', 'Upload the version file before uploading.') + else: + index = _adopt_temp(version_file, project.pk, 'versions', 'version') + version_file.status = 'used' + version_file.save(update_fields=['status']) + Version.objects.create( + project=project, + version_name=form.cleaned_data['version_name'], + file=index, + changelog=form.cleaned_data['changelog'], + ) + messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.') + return redirect('library:project_detail', slug=project.slug) + + version_file = _latest_pending(request.user, 'version') return render(request, 'library/version_upload.html', { 'project': project, 'version_form': form, + 'pending_uploads': [_serialize_temp(version_file)] if version_file else [], }) @@ -556,8 +582,16 @@ def asset_delete(request, slug, asset_id): asset = get_object_or_404(ProjectAsset, pk=asset_id, project=project) if not project.can_edit(request.user): return HttpResponseForbidden('You do not have permission to delete media.') + if request.method != 'POST': + return HttpResponse(status=405) + delete_file_index(asset.file) asset.delete() + + accept = request.META.get('HTTP_ACCEPT', '') + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'application/json' in accept: + return JsonResponse({'success': True}) + messages.success(request, 'Media deleted.') return redirect('library:project_detail', slug=project.slug) diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index 03aea85..2f7d22c 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -1112,25 +1112,6 @@ textarea { font-size: 24px; cursor: pointer; } -.tab-buttons { - display: flex; - gap: 8px; - margin-bottom: 16px; -} -.tab-btn { - background: var(--md-sys-color-surface-variant); - color: var(--md-sys-color-on-surface); -} -.tab-btn.active { - background: var(--md-sys-color-primary); - color: var(--md-sys-color-on-primary); -} -.tab-content { - display: none; -} -.tab-content.active { - display: block; -} .modal-preview { text-align: center; margin-bottom: 16px; @@ -1996,7 +1977,15 @@ a.deletelink { color: inherit; cursor: pointer; font-size: 0.85rem; - padding: 0 2px; + line-height: 1; + padding: 2px 5px; + margin-left: 2px; + border-radius: 50%; + opacity: 0.7; +} +.tag-chip-remove:hover { + opacity: 1; + background: rgba(127, 127, 127, 0.25); } .tag-dot { display: inline-block; @@ -2309,6 +2298,11 @@ a.deletelink { padding: 24px; max-width: 720px; } +.project-form input:not([type='hidden']):not([type='file']), +.project-form select, +.project-form textarea { + width: 100%; +} .form-section-title { margin: 24px 0 8px; } diff --git a/nonpacks/static/js/asset_upload.js b/nonpacks/static/js/asset_upload.js index 2e1a1e9..423a8da 100644 --- a/nonpacks/static/js/asset_upload.js +++ b/nonpacks/static/js/asset_upload.js @@ -1,153 +1,9 @@ -// J621-style multi-upload for the gallery "add media" page. Files are -// uploaded one at a time to /api/uploads/ (kind=media) and previewed; on -// submit the pending uploads are moved into the project's gallery. +// Gallery "add media" page: uses the shared uploads.js engine to upload files +// to /api/uploads/ (kind=media). On submit the pending uploads are moved into +// the project's gallery. (function () { - const zone = document.querySelector('.upload-zone[data-upload="media"]'); - if (!zone) return; - - const csrf = getCookie('csrftoken'); - const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {}; - const preview = document.getElementById('media-preview'); - const batch = document.getElementById('media-batch'); - const input = document.getElementById('media-input'); - const drop = zone.querySelector('.drop-zone'); - const pending = JSON.parse( (document.getElementById('packs-pending-uploads') || { textContent: '[]' }).textContent ); - - pending.forEach(renderCard); - - function renderCard(upload) { - const card = document.createElement('div'); - card.className = 'file-preview-card'; - card.dataset.uuid = upload.uuid; - - if ((upload.content_type || '').startsWith('image/')) { - const img = document.createElement('img'); - img.src = upload.url; - img.alt = upload.filename; - card.appendChild(img); - } else if ((upload.content_type || '').startsWith('video/')) { - const video = document.createElement('video'); - video.src = upload.url; - video.muted = true; - video.autoplay = true; - video.loop = true; - card.appendChild(video); - } else { - const icon = document.createElement('div'); - icon.className = 'file-preview-icon'; - icon.innerHTML = ''; - card.appendChild(icon); - } - - const name = document.createElement('span'); - name.className = 'file-preview-name'; - name.textContent = upload.filename; - card.appendChild(name); - - const remove = document.createElement('button'); - remove.type = 'button'; - remove.className = 'file-preview-remove'; - remove.innerHTML = ''; - remove.addEventListener('click', () => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/uploads/' + upload.uuid + '/delete/'); - for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]); - xhr.onload = () => { if (card.parentNode) card.parentNode.removeChild(card); }; - xhr.onerror = () => alert('Network error.'); - xhr.send(); - }); - card.appendChild(remove); - preview.appendChild(card); - } - - function uploadFile(file) { - const fd = new FormData(); - fd.append('file', file); - fd.append('kind', 'media'); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/uploads/'); - for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]); - xhr.onload = function () { - if (xhr.status !== 200) { - try { - alert(JSON.parse(xhr.responseText).error || 'Upload failed.'); - } catch (e) { - alert('Upload failed.'); - } - } else { - renderCard(JSON.parse(xhr.responseText)); - } - batchDone++; - updateBatch(); - }; - xhr.onerror = function () { - alert('Network error during upload.'); - batchDone++; - updateBatch(); - }; - xhr.send(fd); - } - - let batchTotal = 0; - let batchDone = 0; - function updateBatch() { - if (batchTotal === 0) { batch.hidden = true; return; } - batch.hidden = false; - document.getElementById('media-batch-status').textContent = - 'Uploading ' + batchDone + ' of ' + batchTotal + ' files'; - if (batchDone >= batchTotal && batchTotal > 0) { - setTimeout(() => { batch.hidden = true; batchTotal = 0; batchDone = 0; }, 2500); - } - } - - function handleFiles(files) { - batchTotal += files.length; - updateBatch(); - files.forEach((file) => uploadFile(file)); - } - - drop.addEventListener('click', () => input.click()); - drop.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } - }); - ['dragenter', 'dragover'].forEach((evt) => drop.addEventListener(evt, (e) => { - e.preventDefault(); - e.stopPropagation(); - drop.classList.add('dragover'); - })); - ['dragleave', 'drop'].forEach((evt) => drop.addEventListener(evt, (e) => { - e.preventDefault(); - e.stopPropagation(); - drop.classList.remove('dragover'); - })); - drop.addEventListener('drop', (e) => { - if (e.dataTransfer && e.dataTransfer.files.length) { - handleFiles(Array.from(e.dataTransfer.files)); - } - }); - input.addEventListener('change', () => { - handleFiles(Array.from(input.files)); - input.value = ''; - }); - - ['dragover', 'drop'].forEach((evt) => window.addEventListener(evt, (e) => e.preventDefault())); - - function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; - } + PacksUploads.init({ pending: pending }); })(); diff --git a/nonpacks/static/js/create.js b/nonpacks/static/js/create.js index abbac89..7313c70 100644 --- a/nonpacks/static/js/create.js +++ b/nonpacks/static/js/create.js @@ -1,197 +1,17 @@ -// J621-style upload + real-time autosave for the "create project" form. -// Each selected file is uploaded individually (XHR + FormData) to /api/uploads/ -// and stored under media/uploads/user_/. Typed fields autosave to the -// ProjectDraft via /api/draft/ (debounced, no explicit Save Draft button). +// "Create project" page: autosaves typed fields to the ProjectDraft via +// /api/draft/ (debounced, no explicit Save Draft button). File uploads are +// handled by the shared uploads.js engine (per-file XHR to /api/uploads/). (function () { const form = document.getElementById('project-form'); if (!form) return; - const csrf = getCookie('csrftoken'); - const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {}; - const savedUploads = JSON.parse( + const pending = JSON.parse( (document.getElementById('packs-draft-uploads') || { textContent: '[]' }).textContent ); - // ----- Multi-upload (per-file XHR, thumbnails) ----- - - const zones = {}; - document.querySelectorAll('.upload-zone').forEach((zone) => { - const kind = zone.dataset.upload; - zones[kind] = { - zone, - drop: zone.querySelector('.drop-zone'), - input: zone.querySelector('input[type=file]'), - preview: zone.querySelector('.upload-preview, #media-preview'), - }; - if (kind === 'media') zones[kind].batch = document.getElementById('media-batch'); - }); - - // Re-render the pending uploads that survived from a previous session. - savedUploads.forEach((u) => { - const z = zones[u.kind]; - if (z && !z.current) renderUpload(u, z); - }); - - function renderUpload(upload, z) { - if (z.kind === 'thumbnail' || z.kind === 'version') { - z.preview.innerHTML = ''; - z.preview.appendChild(previewCard(upload)); - z.current = upload; - } else { - z.preview.appendChild(previewCard(upload)); - } - } - - function previewCard(upload) { - const card = document.createElement('div'); - card.className = 'file-preview-card'; - card.dataset.uuid = upload.uuid; - - if ((upload.content_type || '').startsWith('image/')) { - const img = document.createElement('img'); - img.src = upload.url; - img.alt = upload.filename; - card.appendChild(img); - } else if ((upload.content_type || '').startsWith('video/')) { - const video = document.createElement('video'); - video.src = upload.url; - video.muted = true; - video.autoplay = true; - video.loop = true; - card.appendChild(video); - } else { - const icon = document.createElement('div'); - icon.className = 'file-preview-icon'; - icon.innerHTML = ''; - card.appendChild(icon); - } - - const name = document.createElement('span'); - name.className = 'file-preview-name'; - name.textContent = upload.filename; - card.appendChild(name); - - const remove = document.createElement('button'); - remove.type = 'button'; - remove.className = 'file-preview-remove'; - remove.innerHTML = ''; - remove.addEventListener('click', () => deleteUpload(upload.uuid, card)); - card.appendChild(remove); - return card; - } - - function uploadFile(file, kind, z) { - const fd = new FormData(); - fd.append('file', file); - fd.append('kind', kind); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/uploads/'); - for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]); - - xhr.onload = function () { - z.busy = false; - if (xhr.status !== 200) { - try { - alert(JSON.parse(xhr.responseText).error || 'Upload failed.'); - } catch (e) { - alert('Upload failed.'); - } - return; - } - const data = JSON.parse(xhr.responseText); - renderUpload(data, z); - }; - xhr.onerror = function () { - z.busy = false; - alert('Network error during upload.'); - }; - xhr.send(fd); - } - - function deleteUpload(uuid, card) { - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/uploads/' + uuid + '/delete/'); - for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]); - xhr.onload = function () { - if (card && card.parentNode) card.parentNode.removeChild(card); - }; - xhr.onerror = function () { - alert('Network error.'); - }; - xhr.send(); - } - - Object.keys(zones).forEach((kind) => { - const z = zones[kind]; - z.drop.addEventListener('click', () => z.input.click()); - z.drop.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); z.input.click(); } - }); - - ['dragenter', 'dragover'].forEach((evt) => z.drop.addEventListener(evt, (e) => { - e.preventDefault(); - e.stopPropagation(); - z.drop.classList.add('dragover'); - })); - ['dragleave', 'drop'].forEach((evt) => z.drop.addEventListener(evt, (e) => { - e.preventDefault(); - e.stopPropagation(); - z.drop.classList.remove('dragover'); - })); - z.drop.addEventListener('drop', (e) => { - if (e.dataTransfer && e.dataTransfer.files.length) { - handleFiles(Array.from(e.dataTransfer.files), kind, z); - } - }); - z.input.addEventListener('change', () => { - handleFiles(Array.from(z.input.files), kind, z); - z.input.value = ''; - }); - }); - - // Prevent the browser opening files dropped outside a zone. - ['dragover', 'drop'].forEach((evt) => window.addEventListener(evt, (e) => e.preventDefault())); - - let batchTotal = 0; - let batchDone = 0; - - function handleFiles(files, kind, z) { - // Thumbnail/version are single slots: take one file, replace any - // current upload, and ignore further drops while one is in flight. - if (kind === 'thumbnail' || kind === 'version') { - if (z.busy) return; - const file = files[0]; - if (z.current) deleteUpload(z.current.uuid, null); - z.busy = true; - uploadFile(file, kind, z); - return; - } - - batchTotal += files.length; - updateBatch(); - files.forEach((file) => uploadFile(file, kind, z)); - } - - function updateBatch() { - const z = zones.media; - if (!z || !z.batch) return; - if (batchTotal === 0) { z.batch.hidden = true; return; } - z.batch.hidden = false; - document.getElementById('media-batch-status').textContent = - 'Uploading ' + batchDone + ' of ' + batchTotal + ' files'; - if (batchDone >= batchTotal && batchTotal > 0) { - setTimeout(() => { - z.batch.hidden = true; - batchTotal = 0; - batchDone = 0; - }, 2500); - } - } - - // ----- Real-time draft autosave ----- - const autosaveStatus = document.getElementById('autosave-status'); + const csrf = getCookie('csrftoken'); + const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {}; const FIELDS = ['title', 'summary', 'category', 'description', 'caption', 'version_name', 'changelog']; function setStatus(text, state) { @@ -200,7 +20,6 @@ } let saveTimer = null; - let lastSavedAt = null; function scheduleSave() { setStatus(' Saving…', 'saving'); @@ -223,8 +42,6 @@ for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]); xhr.onload = function () { if (xhr.status === 200) { - const r = JSON.parse(xhr.responseText); - lastSavedAt = r.updated_at; setStatus(' Saved just now', 'saved'); } else { setStatus(' Save failed', 'error'); @@ -236,18 +53,21 @@ xhr.send(JSON.stringify({ data })); } - FIELDS.forEach((name) => { - const el = document.getElementById('id_' + name); - if (el) { - el.addEventListener('input', scheduleSave); - if (el.tagName === 'SELECT') el.addEventListener('change', scheduleSave); - } - }); - document.addEventListener('tags-changed', scheduleSave); + function initAutosave() { + FIELDS.forEach((name) => { + const el = document.getElementById('id_' + name); + if (el) { + el.addEventListener('input', scheduleSave); + if (el.tagName === 'SELECT') el.addEventListener('change', scheduleSave); + } + }); + document.addEventListener('tags-changed', scheduleSave); - // Autosave is always live; indicate it once the page settles. - setStatus(' Autosave on', 'idle'); - setTimeout(() => setStatus(' Saved just now', 'saved'), 2000); + setStatus(' Autosave on', 'idle'); + setTimeout(() => setStatus(' Saved just now', 'saved'), 2000); + } + + PacksUploads.init({ pending: pending, onReady: initAutosave }); function getCookie(name) { let cookieValue = null; diff --git a/nonpacks/static/js/edit.js b/nonpacks/static/js/edit.js new file mode 100644 index 0000000..1725378 --- /dev/null +++ b/nonpacks/static/js/edit.js @@ -0,0 +1,9 @@ +// "Edit project" page: uses the shared uploads.js engine for the thumbnail +// (single slot). The existing thumbnail is shown as a read-only preview until +// a replacement is uploaded. +(function () { + const pending = JSON.parse( + (document.getElementById('packs-pending-uploads') || { textContent: '[]' }).textContent + ); + PacksUploads.init({ pending: pending }); +})(); diff --git a/nonpacks/static/js/tags.js b/nonpacks/static/js/tags.js index 429f9c1..68f8fdb 100644 --- a/nonpacks/static/js/tags.js +++ b/nonpacks/static/js/tags.js @@ -9,6 +9,13 @@ const chips = document.getElementById('tag-chips'); const hidden = document.getElementById('id_tags'); + // category slug -> color, provided by the server (json_script). + const colorMap = {}; + try { + const el = document.getElementById('packs-tag-categories'); + if (el) JSON.parse(el.textContent).forEach(c => { colorMap[c.slug] = c.color; }); + } catch (e) { /* ignore */ } + const tags = new Map(); // "category:name" -> {category, name, color} function render() { @@ -23,6 +30,8 @@ x.type = 'button'; x.className = 'tag-chip-remove'; x.textContent = '×'; + x.title = 'Remove tag'; + x.setAttribute('aria-label', 'Remove tag ' + tag.name); x.addEventListener('click', () => { tags.delete(tag.category + ':' + tag.name); render(); }); chip.appendChild(x); chips.appendChild(chip); @@ -33,7 +42,8 @@ function addTag(category, name, color) { const canonical = category.toLowerCase(); - tags.set(canonical + ':' + name, { category: canonical, name, color: color || '#666666' }); + color = color || colorMap[canonical] || '#666666'; + tags.set(canonical + ':' + name, { category: canonical, name, color }); render(); } diff --git a/nonpacks/static/js/uploads.js b/nonpacks/static/js/uploads.js new file mode 100644 index 0000000..e36ed09 --- /dev/null +++ b/nonpacks/static/js/uploads.js @@ -0,0 +1,214 @@ +// Shared J621-style uploader for UGC pages. +// Wires every `.upload-zone[data-upload]` on the page: dropzone click/drag, +// per-file XHR to /api/uploads/, preview cards, removal, batch progress. +// Single slots (thumbnail/version) hold one file; media is multi. +(function () { + function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } + + function init(options) { + const opts = options || {}; + const pending = opts.pending || []; + const csrf = getCookie('csrftoken'); + const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {}; + + const zones = {}; + document.querySelectorAll('.upload-zone[data-upload]').forEach((zone) => { + const kind = zone.dataset.upload; + zones[kind] = { + zone, + kind, + single: kind === 'thumbnail' || kind === 'version', + drop: zone.querySelector('.drop-zone'), + input: zone.querySelector('input[type=file]'), + preview: zone.querySelector('.upload-preview, #media-preview'), + batch: zone.querySelector('.upload-batch'), + current: null, + busy: false, + batchTotal: 0, + batchDone: 0, + }; + }); + + // Render uploads that survived from a previous session. + pending.forEach((u) => { + const z = zones[u.kind]; + if (z) addCard(u, z); + }); + + function previewCard(upload, z) { + const card = document.createElement('div'); + card.className = 'file-preview-card'; + card.dataset.uuid = upload.uuid; + + const ct = upload.content_type || ''; + if (ct.startsWith('image/')) { + const img = document.createElement('img'); + img.src = upload.url; + img.alt = upload.filename || ''; + card.appendChild(img); + } else if (ct.startsWith('video/')) { + const video = document.createElement('video'); + video.src = upload.url; + video.muted = true; + video.autoplay = true; + video.loop = true; + card.appendChild(video); + } else { + const icon = document.createElement('div'); + icon.className = 'file-preview-icon'; + icon.innerHTML = ''; + card.appendChild(icon); + } + + const name = document.createElement('span'); + name.className = 'file-preview-name'; + name.textContent = upload.filename || ''; + card.appendChild(name); + + if (upload.removable !== false) { + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'file-preview-remove'; + remove.innerHTML = ''; + remove.addEventListener('click', () => deleteUpload(upload.uuid, card, z)); + card.appendChild(remove); + } + return card; + } + + function addCard(upload, z) { + if (z.single) { + z.preview.innerHTML = ''; + z.preview.appendChild(previewCard(upload, z)); + if (upload.removable !== false) z.current = upload; + } else { + z.preview.appendChild(previewCard(upload, z)); + } + } + + function uploadFile(file, z) { + const fd = new FormData(); + fd.append('file', file); + fd.append('kind', z.kind); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/uploads/'); + for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]); + + xhr.onload = function () { + z.busy = false; + if (!z.single) { z.batchDone++; updateBatch(z); } + if (xhr.status !== 200) { + try { alert(JSON.parse(xhr.responseText).error || 'Upload failed.'); } + catch (e) { alert('Upload failed.'); } + return; + } + addCard(JSON.parse(xhr.responseText), z); + }; + xhr.onerror = function () { + z.busy = false; + if (!z.single) { z.batchDone++; updateBatch(z); } + alert('Network error during upload.'); + }; + xhr.send(fd); + } + + function deleteUpload(uuid, card, z) { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/uploads/' + uuid + '/delete/'); + for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]); + xhr.onload = function () { + if (card && card.parentNode) card.parentNode.removeChild(card); + if (z && z.current && z.current.uuid === uuid) z.current = null; + if (opts.onRemove) opts.onRemove(uuid); + }; + xhr.onerror = function () { + alert('Network error.'); + }; + xhr.send(); + } + + function handleFiles(files, z) { + // Single slots: take one file, replace the current upload, and + // ignore further drops while one is in flight. + if (z.single) { + if (z.busy) return; + const file = files[0]; + if (z.current) deleteUpload(z.current.uuid, null, z); + z.busy = true; + uploadFile(file, z); + return; + } + z.batchTotal += files.length; + updateBatch(z); + files.forEach((file) => uploadFile(file, z)); + } + + function updateBatch(z) { + if (!z.batch) return; + if (z.batchTotal === 0) { z.batch.hidden = true; return; } + z.batch.hidden = false; + const status = z.batch.querySelector('.upload-batch-status'); + if (status) { + status.textContent = 'Uploading ' + z.batchDone + ' of ' + z.batchTotal + ' files'; + } + if (z.batchDone >= z.batchTotal && z.batchTotal > 0) { + setTimeout(() => { + z.batch.hidden = true; + z.batchTotal = 0; + z.batchDone = 0; + }, 2500); + } + } + + Object.keys(zones).forEach((kind) => { + const z = zones[kind]; + if (!z.drop || !z.input) return; + + z.drop.addEventListener('click', () => z.input.click()); + z.drop.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); z.input.click(); } + }); + + ['dragenter', 'dragover'].forEach((evt) => z.drop.addEventListener(evt, (e) => { + e.preventDefault(); + e.stopPropagation(); + z.drop.classList.add('dragover'); + })); + ['dragleave', 'drop'].forEach((evt) => z.drop.addEventListener(evt, (e) => { + e.preventDefault(); + e.stopPropagation(); + z.drop.classList.remove('dragover'); + })); + z.drop.addEventListener('drop', (e) => { + if (e.dataTransfer && e.dataTransfer.files.length) { + handleFiles(Array.from(e.dataTransfer.files), z); + } + }); + z.input.addEventListener('change', () => { + handleFiles(Array.from(z.input.files), z); + z.input.value = ''; + }); + }); + + // Prevent the browser opening files dropped outside a zone. + ['dragover', 'drop'].forEach((evt) => window.addEventListener(evt, (e) => e.preventDefault())); + + if (opts.onReady) opts.onReady(); + } + + window.PacksUploads = { init: init }; +})(); diff --git a/nonpacks/static/js/version_upload.js b/nonpacks/static/js/version_upload.js new file mode 100644 index 0000000..cbf3134 --- /dev/null +++ b/nonpacks/static/js/version_upload.js @@ -0,0 +1,8 @@ +// "New version" page: uses the shared uploads.js engine for the version file +// (single slot). On submit the pending version temp is moved into the project. +(function () { + const pending = JSON.parse( + (document.getElementById('packs-pending-uploads') || { textContent: '[]' }).textContent + ); + PacksUploads.init({ pending: pending }); +})(); diff --git a/nonpacks/templates/library/_project_form_fields.html b/nonpacks/templates/library/_project_form_fields.html deleted file mode 100644 index 80d967f..0000000 --- a/nonpacks/templates/library/_project_form_fields.html +++ /dev/null @@ -1,26 +0,0 @@ -
- {{ form.title.errors }} - - {{ form.title }} -
- -
- {{ form.summary.errors }} - - {{ form.summary }} -
- -
-
- {{ form.category.errors }} - - {{ form.category }} -
-
- -
- {{ form.description.errors }} - - {{ form.description }} -

Supports Markdown — headings, lists, links, code, tables and more.

-
diff --git a/nonpacks/templates/library/_project_form_side.html b/nonpacks/templates/library/_project_form_side.html deleted file mode 100644 index 1fca4d0..0000000 --- a/nonpacks/templates/library/_project_form_side.html +++ /dev/null @@ -1,17 +0,0 @@ -
- {{ form.thumbnail.errors }} - - {{ form.thumbnail }} -

Square image shown on cards and the project header.

-
- -
- -
- - -
- {{ form.tags }} -
-

Categories: loader, version, general, creator, content, species, non_version. Unprefixed tags go to General. Creator tags are added automatically.

-
diff --git a/nonpacks/templates/library/_version_form_fields.html b/nonpacks/templates/library/_version_form_fields.html deleted file mode 100644 index a1f7ce8..0000000 --- a/nonpacks/templates/library/_version_form_fields.html +++ /dev/null @@ -1,18 +0,0 @@ -
- {{ form.version_name.errors }} - - {{ form.version_name }} -
- -
- {{ form.file.errors }} - - {{ form.file }} -
- -
- {{ form.changelog.errors }} - - {{ form.changelog }} -

Supports Markdown.

-
diff --git a/nonpacks/templates/library/asset_upload.html b/nonpacks/templates/library/asset_upload.html index eca7c3e..910d44f 100644 --- a/nonpacks/templates/library/asset_upload.html +++ b/nonpacks/templates/library/asset_upload.html @@ -21,7 +21,7 @@
@@ -41,5 +41,6 @@ {% endblock %} {% block extra_js %} + {% endblock %} diff --git a/nonpacks/templates/library/project_create.html b/nonpacks/templates/library/project_create.html index 1c6919f..c1922ea 100644 --- a/nonpacks/templates/library/project_create.html +++ b/nonpacks/templates/library/project_create.html @@ -96,7 +96,7 @@
@@ -117,6 +117,8 @@ {% block extra_js %} {{ draft_uploads|json_script:'packs-draft-uploads' }} +{{ tag_categories|json_script:'packs-tag-categories' }} + {% endblock %} diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index f21c356..7d54610 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -135,6 +135,29 @@ document.getElementById('tab-' + btn.dataset.tab).classList.add('active'); }); }); + + // Gallery deletion without a page reload, behind a confirmation dialog. + document.querySelectorAll('.gallery-delete').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + if (!confirm('Delete this media? This cannot be undone.')) return; + const xhr = new XMLHttpRequest(); + xhr.open('POST', link.getAttribute('href')); + xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken')); + xhr.onload = function () { + if (xhr.status === 200) { + const figure = link.closest('figure.gallery-item'); + if (figure) figure.remove(); + } else { + alert('Could not delete media.'); + } + }; + xhr.onerror = function () { + alert('Network error while deleting media.'); + }; + xhr.send(); + }); + }); })(); {% endblock %} diff --git a/nonpacks/templates/library/project_edit.html b/nonpacks/templates/library/project_edit.html index 1ceb723..db386e8 100644 --- a/nonpacks/templates/library/project_edit.html +++ b/nonpacks/templates/library/project_edit.html @@ -7,17 +7,59 @@

Edit {{ project.title }}

← Back to project

-
+ {% csrf_token %} -
-
- {% include 'library/_project_form_fields.html' with form=project_form %} -
+
+
+

Project

- +
+ {{ project_form.title.errors }} + + {{ project_form.title }} +
+
+ {{ project_form.summary.errors }} + + {{ project_form.summary }} +
+
+ {{ project_form.category.errors }} + + {{ project_form.category }} +
+
+ {{ project_form.description.errors }} + + {{ project_form.description }} +

Supports Markdown — headings, lists, links, code, tables and more.

+
+
+ +
+

Thumbnail

+
+
+
+ +

Drag & drop an image

+

or click to browse

+
+
+ +
+
+ +

Tags

+
+ + +
+ {{ project_form.tags }} +
+

Categories: loader, version, general, creator, content, species, non_version. Unprefixed tags go to General.

+
@@ -27,5 +69,9 @@ {% endblock %} {% block extra_js %} +{{ pending_uploads|json_script:'packs-pending-uploads' }} +{{ tag_categories|json_script:'packs-tag-categories' }} + + {% endblock %} diff --git a/nonpacks/templates/library/version_upload.html b/nonpacks/templates/library/version_upload.html index c012f51..f1729e8 100644 --- a/nonpacks/templates/library/version_upload.html +++ b/nonpacks/templates/library/version_upload.html @@ -7,11 +7,52 @@

New version — {{ project.title }}

← Back to project

- + {% csrf_token %} - {% include 'library/_version_form_fields.html' with form=version_form %} + {% if version_form.non_field_errors %} +
+
    + {% for error in version_form.non_field_errors %}
  • {{ error }}
  • {% endfor %} +
+
+ {% endif %} + +
+ {{ version_form.version_name.errors }} + + {{ version_form.version_name }} +
+ +
+ +
+
+
+ +

Drag & drop the pack file

+

or click to browse

+
+
+ +
+
+
+ +
+ {{ version_form.changelog.errors }} + + {{ version_form.changelog }} +

Supports Markdown.

+
+
{% endblock %} + +{% block extra_js %} +{{ pending_uploads|json_script:'packs-pending-uploads' }} + + +{% endblock %}