diff --git a/nonpacks/library/forms.py b/nonpacks/library/forms.py index b3ec410..bcaa196 100644 --- a/nonpacks/library/forms.py +++ b/nonpacks/library/forms.py @@ -21,11 +21,6 @@ class ProjectForm(forms.Form): 'placeholder': 'Write the description in Markdown…', }), ) - caption = forms.CharField( - max_length=128, - required=False, - widget=forms.TextInput(attrs={'placeholder': 'Optional caption for the gallery media'}), - ) tags = forms.CharField( required=False, widget=forms.HiddenInput(), diff --git a/nonpacks/library/migrations/0005_remove_version_file_versionfile.py b/nonpacks/library/migrations/0005_remove_version_file_versionfile.py new file mode 100644 index 0000000..c289968 --- /dev/null +++ b/nonpacks/library/migrations/0005_remove_version_file_versionfile.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0.3 on 2026-08-04 03:26 + +import django.db.models.deletion +from django.db import migrations, models + + +def copy_version_files(apps, schema_editor): + """Move each Version's single file into a VersionFile row.""" + Version = apps.get_model('library', 'Version') + VersionFile = apps.get_model('library', 'VersionFile') + for version in Version.objects.all(): + if version.file_id: + VersionFile.objects.create( + version=version, file_id=version.file_id, kind='release', + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0004_projectdraft_tempupload'), + ] + + operations = [ + migrations.CreateModel( + name='VersionFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('release', 'Release'), ('markdown', 'Markdown'), ('png', 'PNG')], default='release', max_length=16)), + ('note', models.CharField(blank=True, default='', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='version_file', to='library.fileindex')), + ('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='library.version')), + ], + options={ + 'ordering': ['created_at', 'pk'], + }, + ), + migrations.RunPython(copy_version_files, migrations.RunPython.noop), + migrations.RemoveField( + model_name='version', + name='file', + ), + ] diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py index 6ffa6ae..cf87d3a 100644 --- a/nonpacks/library/models.py +++ b/nonpacks/library/models.py @@ -201,12 +201,9 @@ class TagList(models.Model): class Version(models.Model): - """A tracked-file release. Every upload is stored; latest = newest created_at.""" + """A release holding one or more VersionFile entries. Latest = newest.""" project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='versions') version_name = models.CharField(max_length=64) - file = models.ForeignKey( - FileIndex, on_delete=models.SET_NULL, null=True, blank=True, related_name='project_version', - ) changelog = models.TextField(blank=True, default='') downloads = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) @@ -218,6 +215,39 @@ class Version(models.Model): return f'{self.project.title} {self.version_name}' +class VersionFile(models.Model): + """One file belonging to a Version release. A version can hold several + files (e.g. multiple skins), each with its own download.""" + KIND_CHOICES = [ + ('release', 'Release'), + ('markdown', 'Markdown'), + ('png', 'PNG'), + ] + version = models.ForeignKey(Version, on_delete=models.CASCADE, related_name='files') + file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='version_file') + kind = models.CharField(max_length=16, choices=KIND_CHOICES, default='release') + note = models.CharField(max_length=255, blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['created_at', 'pk'] + + def __str__(self): + return f'{self.version} — {self.file.original_filename}' + + @property + def filename(self): + return self.file.original_filename + + @property + def is_markdown(self): + return self.kind == 'markdown' + + @property + def is_png(self): + return self.kind == 'png' + + class ProjectAsset(models.Model): """Untracked gallery media (image/video/gif) displayed on the project page.""" project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='assets') diff --git a/nonpacks/library/tests.py b/nonpacks/library/tests.py index ebee0ed..068f986 100644 --- a/nonpacks/library/tests.py +++ b/nonpacks/library/tests.py @@ -20,6 +20,7 @@ from .models import ( TagList, TempUpload, Version, + VersionFile, ) @@ -113,7 +114,7 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase): self.assertEqual(project.versions.count(), 1) self.assertIsNotNone(project.thumbnail) self.assertTrue(project.thumbnail.stored_path.startswith('project_')) - self.assertTrue(project.versions.first().file.stored_path.startswith('project_')) + self.assertTrue(project.versions.first().files.first().file.stored_path.startswith('project_')) # Temp uploads marked used. self.assertEqual( TempUpload.objects.filter(user=self.alice, status='used').count(), 2 @@ -138,23 +139,31 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase): self.assertEqual(resp.status_code, 200) self.assertFalse(Project.objects.filter(slug='no-file').exists()) - def test_create_adopts_media_and_caption(self): + def test_create_adopts_media_and_captions(self): self.gate() self.client.login(username='Alice', password='pw') self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png') self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip') self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png') self._upload_temp('media', 'pic2.png', make_png_bytes(), 'image/png') + media = TempUpload.objects.filter(kind='media').order_by('created_at') + caption_payload = { + f'caption_{media[0].uuid}': 'Showcase', + f'caption_{media[1].uuid}': 'Second angle', + } resp = self.client.post(reverse('library:project_create'), { 'title': 'Media Pack', 'category': 'skin', 'version_name': '1.0.0', - 'caption': 'Showcase', + **caption_payload, }) self.assertRedirects(resp, reverse('library:project_detail', args=['media-pack'])) project = Project.objects.get(slug='media-pack') self.assertEqual(project.assets.count(), 2) - self.assertEqual(project.assets.first().caption, 'Showcase') + # Per-file captions stored individually. + captions = dict(project.assets.values_list('file__original_filename', 'caption')) + self.assertEqual(captions['pic1.png'], 'Showcase') + self.assertEqual(captions['pic2.png'], 'Second angle') for asset in project.assets.all(): self.assertTrue(asset.file.stored_path.startswith('project_')) @@ -207,13 +216,39 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase): self.assertEqual(self.project.versions.count(), 1) version = self.project.versions.first() self.assertEqual(version.version_name, '2.0.0') - self.assertTrue(version.file_id) + self.assertEqual(version.files.count(), 1) # Adopted file moved under project_/versions/ and temp marked used. - self.assertTrue(version.file.stored_path.startswith('project_')) + self.assertTrue(version.files.first().file.stored_path.startswith('project_')) self.assertTrue( TempUpload.objects.filter(user=self.alice, kind='version', status='used').exists() ) + def test_upload_version_multiple_files(self): + self.gate() + self.client.login(username='Alice', password='pw') + for name in ('skin1.png', 'skin2.png'): + self.client.post( + reverse('library:api_upload_temp'), + {'kind': 'version', 'file': SimpleUploadedFile(name, make_png_bytes(), content_type='image/png')}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + resp = self.client.post( + reverse('library:version_upload', args=['test-pack']), + {'version_name': '3.0.0'}, + ) + self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack'])) + version = self.project.versions.first() + self.assertEqual(version.version_name, '3.0.0') + # One version, several files, PNGs classified as png kind. + self.assertEqual(version.files.count(), 2) + self.assertEqual( + set(version.files.values_list('kind', flat=True)), {'png'} + ) + self.assertEqual( + set(version.files.values_list('file__original_filename', flat=True)), + {'skin1.png', 'skin2.png'}, + ) + def test_upload_version_requires_pending_file(self): self.gate() self.client.login(username='Alice', password='pw') @@ -259,7 +294,8 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase): SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'), self.project.pk, subdir='versions', ) - version = Version.objects.create(project=self.project, version_name='1.0.0', file=index) + version = Version.objects.create(project=self.project, version_name='1.0.0') + VersionFile.objects.create(version=version, file=index, kind='release') self.gate() resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk])) self.assertEqual(resp.status_code, 302) @@ -270,6 +306,29 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase): self.assertEqual(resp.status_code, 200) self.assertEqual(b''.join(resp.streaming_content), make_zip_bytes()) + def test_per_file_download(self): + from library.storage import store_file + + index = store_file( + self.alice, 'version', + SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'), + self.project.pk, subdir='versions', + ) + version = Version.objects.create(project=self.project, version_name='1.0.0') + vf = VersionFile.objects.create(version=version, file=index, kind='release') + self.gate() + resp = self.client.get( + reverse('library:version_file_download', args=['test-pack', version.pk, index.uuid]) + ) + self.assertEqual(resp.status_code, 302) + version.refresh_from_db() + self.assertEqual(version.downloads, 1) + self.assertEqual(resp.url, reverse('library:file_request', args=[index.uuid]) + '?download=1') + # The parent version_download route still works (first file). + resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk])) + self.assertEqual(resp.status_code, 302) + self.assertTrue(vf) + class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase): def setUp(self): @@ -282,7 +341,7 @@ class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase): slug='test-pack', title='Test Pack', category='skin', owner=self.alice, ) - def test_upload_image_asset(self): + def test_upload_image_asset_with_caption(self): self.gate() self.client.login(username='Alice', password='pw') self.client.post( @@ -290,13 +349,38 @@ class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase): {'kind': 'media', 'file': SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) - resp = self.client.post(reverse('library:asset_upload', args=['test-pack']), {'caption': 'Preview'}) + temp = TempUpload.objects.get(kind='media') + resp = self.client.post( + reverse('library:asset_upload', args=['test-pack']), + {f'caption_{temp.uuid}': 'Preview'}, + ) self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack'])) asset = ProjectAsset.objects.get() self.assertEqual(asset.caption, 'Preview') self.assertEqual(asset.uploaded_by, self.alice) self.assertFalse(asset.is_video) + def test_asset_captions_are_per_file(self): + self.gate() + self.client.login(username='Alice', password='pw') + self.client.post( + reverse('library:api_upload_temp'), + {'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(color='red'), content_type='image/png')}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + self.client.post( + reverse('library:api_upload_temp'), + {'kind': 'media', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + temps = list(TempUpload.objects.filter(kind='media').order_by('created_at')) + self.client.post(reverse('library:asset_upload', args=['test-pack']), { + f'caption_{temps[0].uuid}': 'First', + f'caption_{temps[1].uuid}': 'Second', + }) + captions = dict(ProjectAsset.objects.values_list('file__original_filename', 'caption')) + self.assertEqual(captions, {'a.png': 'First', 'b.png': 'Second'}) + def test_reject_non_media_file(self): self.gate() self.client.login(username='Alice', password='pw') @@ -311,7 +395,7 @@ class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase): def test_gallery_upload_requires_pending_media(self): self.gate() self.client.login(username='Alice', password='pw') - resp = self.client.post(reverse('library:asset_upload', args=['test-pack']), {'caption': 'none'}) + resp = self.client.post(reverse('library:asset_upload', args=['test-pack'])) self.assertEqual(resp.status_code, 200) self.assertEqual(ProjectAsset.objects.count(), 0) @@ -427,7 +511,7 @@ class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase): self.assertEqual(resp.status_code, 400) self.assertEqual(TempUpload.objects.count(), 0) - def test_thumbnail_and_version_are_single_slot(self): + def test_thumbnail_single_slot_version_multi(self): self.gate() self.client.login(username='Alice', password='pw') self.client.post( @@ -450,12 +534,12 @@ class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase): {'kind': 'version', 'file': SimpleUploadedFile('v2.zip', make_zip_bytes(), content_type='application/zip')}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', ) - # Only the latest thumbnail + version remain; media unaffected. + # Thumbnail is a single slot; version accepts multiple files. self.assertEqual( TempUpload.objects.filter(kind='thumbnail', status='pending').count(), 1 ) self.assertEqual( - TempUpload.objects.filter(kind='version', status='pending').count(), 1 + TempUpload.objects.filter(kind='version', status='pending').count(), 2 ) def test_temp_upload_delete(self): diff --git a/nonpacks/library/urls.py b/nonpacks/library/urls.py index 6f58972..0705fba 100644 --- a/nonpacks/library/urls.py +++ b/nonpacks/library/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('packs//delete/', views.project_delete, name='project_delete'), path('packs//versions/upload/', views.version_upload, name='version_upload'), path('packs//versions//download/', views.version_download, name='version_download'), + path('packs//versions//files//download/', views.version_file_download, name='version_file_download'), path('packs//gallery/upload/', views.asset_upload, name='asset_upload'), path('packs//gallery//delete/', views.asset_delete, name='asset_delete'), path('packs//contributors/', views.contributors, name='contributors'), diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index d96b5f8..d9c8170 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -25,6 +25,7 @@ from .models import ( TagList, TempUpload, Version, + VersionFile, slugify_tag, ) from .storage import delete_file_index, move_file_index, store_temp_file @@ -307,7 +308,7 @@ def project_detail(request, slug): project = get_object_or_404( Project.objects.select_related('owner', 'thumbnail'), slug=slug, ) - versions = project.versions.select_related('file').all() + versions = project.versions.prefetch_related('files__file').all() assets = project.assets.select_related('file').all() tags = project.tag_links.select_related('tag__category').all() can_edit = project.can_edit(request.user) @@ -323,7 +324,7 @@ def project_detail(request, slug): DRAFT_KEYS = ( 'title', 'summary', 'category', 'description', - 'tags', 'caption', 'version_name', 'changelog', + 'tags', 'version_name', 'changelog', ) @@ -344,6 +345,13 @@ def _pending_uploads(user): ) +def _pending_kind(user, kind): + return list( + TempUpload.objects.filter(user=user, status='pending', kind=kind) + .select_related('file').order_by('created_at') + ) + + def _latest_pending(user, kind): return ( TempUpload.objects.filter(user=user, status='pending', kind=kind) @@ -358,6 +366,35 @@ def _adopt_temp(upload, project_pk, subdir, kind): return move_file_index(upload.file, new_path, kind=kind) +def _classify_version_file(file_index, category): + """Pick a VersionFile.kind from the uploaded file's extension.""" + name = (file_index.original_filename or '').lower() + if name.endswith('.md'): + return 'markdown' + if name.endswith('.png'): + return 'png' + return 'release' + + +def _finalize_version(project, version_name, changelog, temp_uploads): + """Adopt pending 'version' temp uploads into a new Version as VersionFiles.""" + version = Version.objects.create( + project=project, + version_name=version_name, + changelog=changelog, + ) + for temp in temp_uploads: + index = _adopt_temp(temp, project.pk, 'versions', 'version') + VersionFile.objects.create( + version=version, + file=index, + kind=_classify_version_file(index, project.category), + ) + temp.status = 'used' + temp.save(update_fields=['status']) + return version + + def _form_values(*forms): out = {} for form in forms: @@ -376,9 +413,9 @@ def project_create(request): 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') - if version_file is None: - version_form.add_error('version_name', 'Upload the version file before creating the project.') + version_files = _pending_kind(request.user, 'version') + if not version_files: + version_form.add_error('version_name', 'Upload at least one version file before creating the project.') else: project = Project.objects.create( slug=_unique_project_slug(data['title']), @@ -396,24 +433,21 @@ def project_create(request): thumb.save(update_fields=['status']) project.save(update_fields=['thumbnail']) - version_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=version_form.cleaned_data['version_name'], - file=version_index, - changelog=version_form.cleaned_data['changelog'], + _finalize_version( + project, + version_form.cleaned_data['version_name'], + version_form.cleaned_data['changelog'], + version_files, ) - caption = data.get('caption', '') for temp in TempUpload.objects.filter( user=request.user, status='pending', kind='media', ).select_related('file').order_by('created_at'): index = _adopt_temp(temp, project.pk, 'gallery', 'asset') ProjectAsset.objects.create( project=project, file=index, - caption=caption, uploaded_by=request.user, + caption=request.POST.get(f'caption_{temp.uuid}', '')[:128], + uploaded_by=request.user, ) temp.status = 'used' temp.save(update_fields=['status']) @@ -511,7 +545,8 @@ def project_delete(request, slug): indices = [] if project.thumbnail_id: indices.append(project.thumbnail) - indices += [v.file for v in project.versions.all() if v.file_id] + for version in project.versions.prefetch_related('files__file').all(): + indices += [vf.file for vf in version.files.all() if vf.file_id] indices += [a.file for a in project.assets.all() if a.file_id] project.delete() for index in indices: @@ -531,41 +566,56 @@ def version_upload(request, slug): if request.method == 'POST': form = VersionForm(request.POST) if form.is_valid(): - version_file = _latest_pending(request.user, 'version') - if version_file is None: - form.add_error('version_name', 'Upload the version file before uploading.') + version_files = _pending_kind(request.user, 'version') + if not version_files: + form.add_error('version_name', 'Upload at least one 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'], + _finalize_version( + project, + form.cleaned_data['version_name'], + form.cleaned_data['changelog'], + version_files, ) 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') + version_files = _pending_kind(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 [], + 'pending_uploads': [_serialize_temp(u) for u in version_files], }) +def _bump_and_redirect(request, version, file_index): + Version.objects.filter(pk=version.pk).update(downloads=F('downloads') + 1) + return redirect( + reverse('library:file_request', args=[file_index.uuid]) + '?download=1' + ) + + def version_download(request, slug, version_id): + """Download the first release file of a version (kept for API/mods).""" version = get_object_or_404( - Version.objects.select_related('file', 'project'), + Version.objects.select_related('project'), pk=version_id, project__slug=slug, ) - if version.file_id: - Version.objects.filter(pk=version.pk).update(downloads=F('downloads') + 1) - return redirect( - reverse('library:file_request', args=[version.file.uuid]) + '?download=1' - ) - raise Http404 + first = version.files.select_related('file').first() + if first is None: + raise Http404 + return _bump_and_redirect(request, version, first.file) + + +def version_file_download(request, slug, version_id, file_uuid): + """Download a specific file of a version.""" + version = get_object_or_404( + Version.objects.select_related('project'), + pk=version_id, project__slug=slug, + ) + vf = get_object_or_404( + version.files.select_related('file'), file__uuid=file_uuid, + ) + return _bump_and_redirect(request, version, vf.file) @login_required @@ -575,7 +625,6 @@ def asset_upload(request, slug): return HttpResponseForbidden('You do not have permission to add media.') if request.method == 'POST': - caption = request.POST.get('caption', '') media = TempUpload.objects.filter(user=request.user, status='pending', kind='media') if not media.exists(): messages.error(request, 'No media uploaded yet.') @@ -585,7 +634,8 @@ def asset_upload(request, slug): index = _adopt_temp(temp, project.pk, 'gallery', 'asset') ProjectAsset.objects.create( project=project, file=index, - caption=caption, uploaded_by=request.user, + caption=request.POST.get(f'caption_{temp.uuid}', '')[:128], + uploaded_by=request.user, ) temp.status = 'used' temp.save(update_fields=['status']) @@ -595,7 +645,6 @@ def asset_upload(request, slug): return render(request, 'library/asset_upload.html', { 'project': project, - 'caption': request.POST.get('caption', ''), 'pending_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)], }) @@ -707,9 +756,10 @@ def api_upload_temp(request): if kind == 'thumbnail' and not content_type.startswith('image/'): return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400) - # Thumbnail and version are single slots: discard any earlier pending one. - if kind in ('thumbnail', 'version'): - for old in TempUpload.objects.filter(user=request.user, status='pending', kind=kind): + # Thumbnail is a single slot: discard any earlier pending one. Version and + # media accept multiple files (one Version can hold several files). + if kind == 'thumbnail': + for old in TempUpload.objects.filter(user=request.user, status='pending', kind='thumbnail'): delete_file_index(old.file) old.delete() diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index cabb706..fb895bc 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -2705,3 +2705,71 @@ a.deletelink { font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant); } + +/* ========== Project ID badge ========== */ +.project-id-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: var(--md-sys-color-surface-variant); + color: var(--md-sys-color-on-surface-variant); + font-size: 0.72rem; + font-weight: 600; + vertical-align: middle; + margin-left: 6px; +} + +/* ========== Per-file version list ========== */ +.version-downloads { + display: inline-block; + margin-left: 12px; + font-size: 0.75rem; + color: var(--md-sys-color-on-surface-variant); +} +.version-files { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} +.version-file { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--md-sys-color-outline-variant); + background: var(--md-sys-color-surface-variant); + max-width: 100%; +} +.version-file-thumb { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 6px; +} +.version-file-icon { + font-size: 1.4rem; + color: var(--md-sys-color-on-surface-variant); +} +.version-file-name { + font-size: 0.8rem; + word-break: break-all; +} + +/* ========== Per-file caption on media previews ========== */ +.file-caption { + position: absolute; + top: 6px; + left: 6px; + width: calc(100% - 44px); + background: rgba(0, 0, 0, 0.55); + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.7rem; + padding: 3px 6px; +} +.file-caption::placeholder { + color: rgba(255, 255, 255, 0.7); +} diff --git a/nonpacks/static/js/create.js b/nonpacks/static/js/create.js index 7313c70..9ff31a8 100644 --- a/nonpacks/static/js/create.js +++ b/nonpacks/static/js/create.js @@ -12,7 +12,7 @@ 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']; + const FIELDS = ['title', 'summary', 'category', 'description', 'version_name', 'changelog']; function setStatus(text, state) { autosaveStatus.innerHTML = text; diff --git a/nonpacks/static/js/uploads.js b/nonpacks/static/js/uploads.js index e36ed09..e1bf20f 100644 --- a/nonpacks/static/js/uploads.js +++ b/nonpacks/static/js/uploads.js @@ -30,10 +30,10 @@ zones[kind] = { zone, kind, - single: kind === 'thumbnail' || kind === 'version', + single: kind === 'thumbnail', drop: zone.querySelector('.drop-zone'), input: zone.querySelector('input[type=file]'), - preview: zone.querySelector('.upload-preview, #media-preview'), + preview: zone.querySelector('.file-preview-grid, .upload-preview'), batch: zone.querySelector('.upload-batch'), current: null, busy: false, @@ -78,6 +78,15 @@ name.textContent = upload.filename || ''; card.appendChild(name); + if (z.kind === 'media') { + const cap = document.createElement('input'); + cap.type = 'text'; + cap.className = 'file-caption'; + cap.name = 'caption_' + upload.uuid; + cap.placeholder = 'caption…'; + card.appendChild(cap); + } + if (upload.removable !== false) { const remove = document.createElement('button'); remove.type = 'button'; diff --git a/nonpacks/templates/library/asset_upload.html b/nonpacks/templates/library/asset_upload.html index 910d44f..85f9e88 100644 --- a/nonpacks/templates/library/asset_upload.html +++ b/nonpacks/templates/library/asset_upload.html @@ -26,12 +26,6 @@
-
- - -
-
diff --git a/nonpacks/templates/library/project_create.html b/nonpacks/templates/library/project_create.html index c1922ea..06231a3 100644 --- a/nonpacks/templates/library/project_create.html +++ b/nonpacks/templates/library/project_create.html @@ -70,12 +70,12 @@
-

Drag & drop the pack file

-

or click to browse

+

Drag & drop the version files

+

or click to browse — several at once

- -
+ +
{{ version_form.changelog.errors }} @@ -100,11 +100,6 @@
-
- {{ project_form.caption.errors }} - - {{ project_form.caption }} -
diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index 7d54610..dff63b0 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -17,6 +17,7 @@

{{ project.title }}

{{ project.get_category_display }} + #{{ project.pk }}
{% if project.summary %}

{{ project.summary }}

{% endif %}
@@ -85,13 +86,26 @@ {% if forloop.first %}latest{% endif %}
{{ version.created_at|date:"M j, Y" }} + {{ version.downloads }} {% if version.changelog %}
{{ version.changelog|markdown }}
{% endif %} - - {{ version.downloads }} - +
+ {% for vf in version.files.all %} +
+ {% if vf.is_png %} + {{ vf.filename }} + {% else %} + + {% endif %} + {{ vf.filename }} + + + +
+ {% endfor %} +
{% empty %}

No versions yet.

diff --git a/nonpacks/templates/library/project_edit.html b/nonpacks/templates/library/project_edit.html index db386e8..a7380f4 100644 --- a/nonpacks/templates/library/project_edit.html +++ b/nonpacks/templates/library/project_edit.html @@ -4,7 +4,7 @@ {% block title %}Edit {{ project.title }} - Packs Site{% endblock %} {% block content %} -

Edit {{ project.title }}

+

Edit {{ project.title }} #{{ project.pk }}

← Back to project

diff --git a/nonpacks/templates/library/version_upload.html b/nonpacks/templates/library/version_upload.html index f1729e8..0842cdf 100644 --- a/nonpacks/templates/library/version_upload.html +++ b/nonpacks/templates/library/version_upload.html @@ -24,17 +24,17 @@
- +
-

Drag & drop the pack file

-

or click to browse

+

Drag & drop the version files

+

or click to browse — several at once

- -
+ +