working UGC content upload with multi-upload support
This commit is contained in:
@@ -21,7 +21,6 @@ class ProjectForm(forms.Form):
|
|||||||
'placeholder': 'Write the description in Markdown…',
|
'placeholder': 'Write the description in Markdown…',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
thumbnail = forms.ImageField(required=False)
|
|
||||||
caption = forms.CharField(
|
caption = forms.CharField(
|
||||||
max_length=128,
|
max_length=128,
|
||||||
required=False,
|
required=False,
|
||||||
@@ -35,19 +34,11 @@ class ProjectForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class VersionForm(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(
|
version_name = forms.CharField(
|
||||||
max_length=64,
|
max_length=64,
|
||||||
label='Version',
|
label='Version',
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'e.g. 1.0.0'}),
|
widget=forms.TextInput(attrs={'placeholder': 'e.g. 1.0.0'}),
|
||||||
)
|
)
|
||||||
file = forms.FileField(label='File')
|
|
||||||
changelog = forms.CharField(
|
changelog = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Textarea(attrs={
|
widget=forms.Textarea(attrs={
|
||||||
|
|||||||
+101
-16
@@ -194,29 +194,42 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
|
|||||||
def test_upload_version_owner(self):
|
def test_upload_version_owner(self):
|
||||||
self.gate()
|
self.gate()
|
||||||
self.client.login(username='Alice', password='pw')
|
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(
|
resp = self.client.post(
|
||||||
reverse('library:version_upload', args=['test-pack']),
|
reverse('library:version_upload', args=['test-pack']),
|
||||||
{
|
{'version_name': '2.0.0', 'changelog': 'More stuff.'},
|
||||||
'version_name': '2.0.0',
|
|
||||||
'changelog': 'More stuff.',
|
|
||||||
'file': SimpleUploadedFile('pack2.zip', make_zip_bytes(), content_type='application/zip'),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
|
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
|
||||||
self.assertEqual(self.project.versions.count(), 1)
|
self.assertEqual(self.project.versions.count(), 1)
|
||||||
self.assertEqual(self.project.versions.first().version_name, '2.0.0')
|
version = self.project.versions.first()
|
||||||
# Newest is latest (ordering).
|
self.assertEqual(version.version_name, '2.0.0')
|
||||||
self.assertTrue(self.project.versions.first().file_id)
|
self.assertTrue(version.file_id)
|
||||||
|
# Adopted file moved under project_<pk>/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):
|
def test_upload_version_non_contributor_denied(self):
|
||||||
self.gate()
|
self.gate()
|
||||||
self.client.login(username='Bob', password='pw')
|
self.client.login(username='Bob', password='pw')
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
reverse('library:version_upload', args=['test-pack']),
|
reverse('library:version_upload', args=['test-pack']),
|
||||||
{
|
{'version_name': '2.0.0'},
|
||||||
'version_name': '2.0.0',
|
|
||||||
'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, 403)
|
self.assertEqual(resp.status_code, 403)
|
||||||
self.assertEqual(self.project.versions.count(), 0)
|
self.assertEqual(self.project.versions.count(), 0)
|
||||||
@@ -226,12 +239,14 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
|
|||||||
self.project.sync_creator_tags()
|
self.project.sync_creator_tags()
|
||||||
self.gate()
|
self.gate()
|
||||||
self.client.login(username='Bob', password='pw')
|
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(
|
resp = self.client.post(
|
||||||
reverse('library:version_upload', args=['test-pack']),
|
reverse('library:version_upload', args=['test-pack']),
|
||||||
{
|
{'version_name': '2.0.0'},
|
||||||
'version_name': '2.0.0',
|
|
||||||
'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, 302)
|
self.assertEqual(resp.status_code, 302)
|
||||||
self.assertEqual(self.project.versions.count(), 1)
|
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.assertFalse(Project.objects.filter(pk=self.project.pk).exists() is False) # project intact
|
||||||
self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())
|
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):
|
def test_asset_delete_non_contributor_denied(self):
|
||||||
from library.storage import store_file
|
from library.storage import store_file
|
||||||
|
|
||||||
@@ -515,6 +564,42 @@ class UGCPermissionTests(UGCMediaTestCase, UGCGatedTestCase):
|
|||||||
self.project.refresh_from_db()
|
self.project.refresh_from_db()
|
||||||
self.assertEqual(self.project.title, 'Renamed Pack')
|
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):
|
def test_edit_non_contributor_denied(self):
|
||||||
self.gate()
|
self.gate()
|
||||||
self.client.login(username='Bob', password='pw')
|
self.client.login(username='Bob', password='pw')
|
||||||
|
|||||||
+57
-23
@@ -27,7 +27,7 @@ from .models import (
|
|||||||
Version,
|
Version,
|
||||||
slugify_tag,
|
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):
|
def _open_indexed_file(file_index):
|
||||||
@@ -345,11 +345,11 @@ def _form_values(*forms):
|
|||||||
@login_required
|
@login_required
|
||||||
def project_create(request):
|
def project_create(request):
|
||||||
project_form = ProjectForm()
|
project_form = ProjectForm()
|
||||||
version_form = VersionForm(required_file=False)
|
version_form = VersionForm()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
project_form = ProjectForm(request.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():
|
if project_form.is_valid() and version_form.is_valid():
|
||||||
data = project_form.cleaned_data
|
data = project_form.cleaned_data
|
||||||
version_file = _latest_pending(request.user, 'version')
|
version_file = _latest_pending(request.user, 'version')
|
||||||
@@ -408,7 +408,6 @@ def project_create(request):
|
|||||||
initial={
|
initial={
|
||||||
k: initial[k] for k in ('version_name', 'changelog') if k in initial
|
k: initial[k] for k in ('version_name', 'changelog') if k in initial
|
||||||
},
|
},
|
||||||
required_file=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(request, 'library/project_create.html', {
|
return render(request, 'library/project_create.html', {
|
||||||
@@ -416,6 +415,7 @@ def project_create(request):
|
|||||||
'version_form': version_form,
|
'version_form': version_form,
|
||||||
'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
|
'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
|
||||||
'draft_data': _form_values(project_form, version_form),
|
'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.')
|
return HttpResponseForbidden('You do not have permission to edit this project.')
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = ProjectForm(request.POST, request.FILES)
|
form = ProjectForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
project.title = data['title']
|
project.title = data['title']
|
||||||
project.summary = data['summary']
|
project.summary = data['summary']
|
||||||
project.description = data['description']
|
project.description = data['description']
|
||||||
project.category = data['category']
|
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)
|
delete_file_index(project.thumbnail)
|
||||||
project.thumbnail = store_file(
|
project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail')
|
||||||
request.user, 'thumbnail', thumbnail, project.pk, subdir='',
|
thumb.status = 'used'
|
||||||
)
|
thumb.save(update_fields=['status'])
|
||||||
|
|
||||||
project.save()
|
project.save()
|
||||||
_apply_tags(project, data['tags'], request.user)
|
_apply_tags(project, data['tags'], request.user)
|
||||||
project.sync_creator_tags(actor=request.user)
|
project.sync_creator_tags(actor=request.user)
|
||||||
@@ -452,9 +454,27 @@ def project_edit(request, slug):
|
|||||||
'category': project.category,
|
'category': project.category,
|
||||||
'tags': _current_tags(project),
|
'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', {
|
return render(request, 'library/project_edit.html', {
|
||||||
'project': project,
|
'project': project,
|
||||||
'project_form': form,
|
'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()
|
form = VersionForm()
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = VersionForm(request.POST, request.FILES)
|
form = VersionForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
index = store_file(
|
version_file = _latest_pending(request.user, 'version')
|
||||||
request.user, 'version', form.cleaned_data['file'],
|
if version_file is None:
|
||||||
project.pk, subdir='versions',
|
form.add_error('version_name', 'Upload the version file before uploading.')
|
||||||
)
|
else:
|
||||||
Version.objects.create(
|
index = _adopt_temp(version_file, project.pk, 'versions', 'version')
|
||||||
project=project,
|
version_file.status = 'used'
|
||||||
version_name=form.cleaned_data['version_name'],
|
version_file.save(update_fields=['status'])
|
||||||
file=index,
|
Version.objects.create(
|
||||||
changelog=form.cleaned_data['changelog'],
|
project=project,
|
||||||
)
|
version_name=form.cleaned_data['version_name'],
|
||||||
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
|
file=index,
|
||||||
return redirect('library:project_detail', slug=project.slug)
|
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', {
|
return render(request, 'library/version_upload.html', {
|
||||||
'project': project,
|
'project': project,
|
||||||
'version_form': form,
|
'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)
|
asset = get_object_or_404(ProjectAsset, pk=asset_id, project=project)
|
||||||
if not project.can_edit(request.user):
|
if not project.can_edit(request.user):
|
||||||
return HttpResponseForbidden('You do not have permission to delete media.')
|
return HttpResponseForbidden('You do not have permission to delete media.')
|
||||||
|
if request.method != 'POST':
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
|
||||||
delete_file_index(asset.file)
|
delete_file_index(asset.file)
|
||||||
asset.delete()
|
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)
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1112,25 +1112,6 @@ textarea {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: pointer;
|
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 {
|
.modal-preview {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -1996,7 +1977,15 @@ a.deletelink {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
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 {
|
.tag-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -2309,6 +2298,11 @@ a.deletelink {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
.project-form input:not([type='hidden']):not([type='file']),
|
||||||
|
.project-form select,
|
||||||
|
.project-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.form-section-title {
|
.form-section-title {
|
||||||
margin: 24px 0 8px;
|
margin: 24px 0 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,153 +1,9 @@
|
|||||||
// J621-style multi-upload for the gallery "add media" page. Files are
|
// Gallery "add media" page: uses the shared uploads.js engine to upload files
|
||||||
// uploaded one at a time to /api/uploads/ (kind=media) and previewed; on
|
// to /api/uploads/ (kind=media). On submit the pending uploads are moved into
|
||||||
// submit the pending uploads are moved into the project's gallery.
|
// the project's gallery.
|
||||||
(function () {
|
(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(
|
const pending = JSON.parse(
|
||||||
(document.getElementById('packs-pending-uploads') || { textContent: '[]' }).textContent
|
(document.getElementById('packs-pending-uploads') || { textContent: '[]' }).textContent
|
||||||
);
|
);
|
||||||
|
PacksUploads.init({ pending: pending });
|
||||||
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 = '<i class="fas fa-file"></i>';
|
|
||||||
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 = '<i class="fas fa-times"></i>';
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+20
-200
@@ -1,197 +1,17 @@
|
|||||||
// J621-style upload + real-time autosave for the "create project" form.
|
// "Create project" page: autosaves typed fields to the ProjectDraft via
|
||||||
// Each selected file is uploaded individually (XHR + FormData) to /api/uploads/
|
// /api/draft/ (debounced, no explicit Save Draft button). File uploads are
|
||||||
// and stored under media/uploads/user_<id>/. Typed fields autosave to the
|
// handled by the shared uploads.js engine (per-file XHR to /api/uploads/).
|
||||||
// ProjectDraft via /api/draft/ (debounced, no explicit Save Draft button).
|
|
||||||
(function () {
|
(function () {
|
||||||
const form = document.getElementById('project-form');
|
const form = document.getElementById('project-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
const csrf = getCookie('csrftoken');
|
const pending = JSON.parse(
|
||||||
const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {};
|
|
||||||
const savedUploads = JSON.parse(
|
|
||||||
(document.getElementById('packs-draft-uploads') || { textContent: '[]' }).textContent
|
(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 = '<i class="fas fa-file-archive"></i>';
|
|
||||||
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 = '<i class="fas fa-times"></i>';
|
|
||||||
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 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', 'caption', 'version_name', 'changelog'];
|
||||||
|
|
||||||
function setStatus(text, state) {
|
function setStatus(text, state) {
|
||||||
@@ -200,7 +20,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let saveTimer = null;
|
let saveTimer = null;
|
||||||
let lastSavedAt = null;
|
|
||||||
|
|
||||||
function scheduleSave() {
|
function scheduleSave() {
|
||||||
setStatus('<i class="fas fa-circle-notch fa-spin"></i> Saving…', 'saving');
|
setStatus('<i class="fas fa-circle-notch fa-spin"></i> Saving…', 'saving');
|
||||||
@@ -223,8 +42,6 @@
|
|||||||
for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]);
|
for (const k in csrfHeader) xhr.setRequestHeader(k, csrfHeader[k]);
|
||||||
xhr.onload = function () {
|
xhr.onload = function () {
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
const r = JSON.parse(xhr.responseText);
|
|
||||||
lastSavedAt = r.updated_at;
|
|
||||||
setStatus('<i class="fas fa-check"></i> Saved just now', 'saved');
|
setStatus('<i class="fas fa-check"></i> Saved just now', 'saved');
|
||||||
} else {
|
} else {
|
||||||
setStatus('<i class="fas fa-exclamation-triangle"></i> Save failed', 'error');
|
setStatus('<i class="fas fa-exclamation-triangle"></i> Save failed', 'error');
|
||||||
@@ -236,18 +53,21 @@
|
|||||||
xhr.send(JSON.stringify({ data }));
|
xhr.send(JSON.stringify({ data }));
|
||||||
}
|
}
|
||||||
|
|
||||||
FIELDS.forEach((name) => {
|
function initAutosave() {
|
||||||
const el = document.getElementById('id_' + name);
|
FIELDS.forEach((name) => {
|
||||||
if (el) {
|
const el = document.getElementById('id_' + name);
|
||||||
el.addEventListener('input', scheduleSave);
|
if (el) {
|
||||||
if (el.tagName === 'SELECT') el.addEventListener('change', scheduleSave);
|
el.addEventListener('input', scheduleSave);
|
||||||
}
|
if (el.tagName === 'SELECT') el.addEventListener('change', scheduleSave);
|
||||||
});
|
}
|
||||||
document.addEventListener('tags-changed', scheduleSave);
|
});
|
||||||
|
document.addEventListener('tags-changed', scheduleSave);
|
||||||
|
|
||||||
// Autosave is always live; indicate it once the page settles.
|
setStatus('<i class="fas fa-cloud-upload-alt"></i> Autosave on', 'idle');
|
||||||
setStatus('<i class="fas fa-cloud-upload-alt"></i> Autosave on', 'idle');
|
setTimeout(() => setStatus('<i class="fas fa-check"></i> Saved just now', 'saved'), 2000);
|
||||||
setTimeout(() => setStatus('<i class="fas fa-check"></i> Saved just now', 'saved'), 2000);
|
}
|
||||||
|
|
||||||
|
PacksUploads.init({ pending: pending, onReady: initAutosave });
|
||||||
|
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
let cookieValue = null;
|
let cookieValue = null;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -9,6 +9,13 @@
|
|||||||
const chips = document.getElementById('tag-chips');
|
const chips = document.getElementById('tag-chips');
|
||||||
const hidden = document.getElementById('id_tags');
|
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}
|
const tags = new Map(); // "category:name" -> {category, name, color}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -23,6 +30,8 @@
|
|||||||
x.type = 'button';
|
x.type = 'button';
|
||||||
x.className = 'tag-chip-remove';
|
x.className = 'tag-chip-remove';
|
||||||
x.textContent = '×';
|
x.textContent = '×';
|
||||||
|
x.title = 'Remove tag';
|
||||||
|
x.setAttribute('aria-label', 'Remove tag ' + tag.name);
|
||||||
x.addEventListener('click', () => { tags.delete(tag.category + ':' + tag.name); render(); });
|
x.addEventListener('click', () => { tags.delete(tag.category + ':' + tag.name); render(); });
|
||||||
chip.appendChild(x);
|
chip.appendChild(x);
|
||||||
chips.appendChild(chip);
|
chips.appendChild(chip);
|
||||||
@@ -33,7 +42,8 @@
|
|||||||
|
|
||||||
function addTag(category, name, color) {
|
function addTag(category, name, color) {
|
||||||
const canonical = category.toLowerCase();
|
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();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '<i class="fas fa-file-archive"></i>';
|
||||||
|
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 = '<i class="fas fa-times"></i>';
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<div class="form-group">
|
|
||||||
{{ form.title.errors }}
|
|
||||||
<label for="{{ form.title.id_for_label }}">Title</label>
|
|
||||||
{{ form.title }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.summary.errors }}
|
|
||||||
<label for="{{ form.summary.id_for_label }}">Summary</label>
|
|
||||||
{{ form.summary }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.category.errors }}
|
|
||||||
<label for="{{ form.category.id_for_label }}">Category</label>
|
|
||||||
{{ form.category }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.description.errors }}
|
|
||||||
<label for="{{ form.description.id_for_label }}">Description</label>
|
|
||||||
{{ form.description }}
|
|
||||||
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more.</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<div class="form-group">
|
|
||||||
{{ form.thumbnail.errors }}
|
|
||||||
<label for="{{ form.thumbnail.id_for_label }}">Thumbnail</label>
|
|
||||||
{{ form.thumbnail }}
|
|
||||||
<p class="bio-help"><i class="fas fa-info-circle"></i> Square image shown on cards and the project header.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Tags</label>
|
|
||||||
<div class="tag-editor">
|
|
||||||
<input type="text" id="tag-input" placeholder="Type a tag and press Enter… (e.g. content:threesome, species:dragon)" autocomplete="off">
|
|
||||||
<div class="tag-suggestions" id="tag-suggestions" hidden></div>
|
|
||||||
<div class="tag-chips" id="tag-chips"></div>
|
|
||||||
{{ form.tags }}
|
|
||||||
</div>
|
|
||||||
<p class="bio-help"><i class="fas fa-info-circle"></i> Categories: loader, version, general, creator, content, species, non_version. Unprefixed tags go to General. Creator tags are added automatically.</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<div class="form-group">
|
|
||||||
{{ form.version_name.errors }}
|
|
||||||
<label for="{{ form.version_name.id_for_label }}">{{ form.version_name.label }}</label>
|
|
||||||
{{ form.version_name }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.file.errors }}
|
|
||||||
<label for="{{ form.file.id_for_label }}">{{ form.file.label }}</label>
|
|
||||||
{{ form.file }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.changelog.errors }}
|
|
||||||
<label for="{{ form.changelog.id_for_label }}">{{ form.changelog.label }}</label>
|
|
||||||
{{ form.changelog }}
|
|
||||||
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown.</p>
|
|
||||||
</div>
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<input type="file" id="media-input" accept="image/*,video/*" multiple hidden>
|
<input type="file" id="media-input" accept="image/*,video/*" multiple hidden>
|
||||||
<div class="upload-batch" id="media-batch" hidden>
|
<div class="upload-batch" id="media-batch" hidden>
|
||||||
<i class="fas fa-circle-notch fa-spin"></i>
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
<span id="media-batch-status"></span>
|
<span class="upload-batch-status" id="media-batch-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-preview-grid" id="media-preview"></div>
|
<div class="file-preview-grid" id="media-preview"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,5 +41,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'js/uploads.js' %}"></script>
|
||||||
<script src="{% static 'js/asset_upload.js' %}"></script>
|
<script src="{% static 'js/asset_upload.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<input type="file" id="media-input" accept="image/*,video/*" multiple hidden>
|
<input type="file" id="media-input" accept="image/*,video/*" multiple hidden>
|
||||||
<div class="upload-batch" id="media-batch" hidden>
|
<div class="upload-batch" id="media-batch" hidden>
|
||||||
<i class="fas fa-circle-notch fa-spin"></i>
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
<span id="media-batch-status"></span>
|
<span class="upload-batch-status" id="media-batch-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-preview-grid" id="media-preview"></div>
|
<div class="file-preview-grid" id="media-preview"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +117,8 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{{ draft_uploads|json_script:'packs-draft-uploads' }}
|
{{ draft_uploads|json_script:'packs-draft-uploads' }}
|
||||||
|
{{ tag_categories|json_script:'packs-tag-categories' }}
|
||||||
<script src="{% static 'js/tags.js' %}"></script>
|
<script src="{% static 'js/tags.js' %}"></script>
|
||||||
|
<script src="{% static 'js/uploads.js' %}"></script>
|
||||||
<script src="{% static 'js/create.js' %}"></script>
|
<script src="{% static 'js/create.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -135,6 +135,29 @@
|
|||||||
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,17 +7,59 @@
|
|||||||
<h1><i class="fas fa-pen"></i> Edit {{ project.title }}</h1>
|
<h1><i class="fas fa-pen"></i> Edit {{ project.title }}</h1>
|
||||||
<p><a href="{% url 'library:project_detail' project.slug %}">← Back to project</a></p>
|
<p><a href="{% url 'library:project_detail' project.slug %}">← Back to project</a></p>
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data" class="card project-form" id="project-form">
|
<form method="post" class="card project-form create-form" id="project-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="project-form-grid">
|
<div class="create-grid">
|
||||||
<div class="project-form-main">
|
<section class="create-panel create-panel-main">
|
||||||
{% include 'library/_project_form_fields.html' with form=project_form %}
|
<h2 class="form-section-title"><i class="fas fa-folder-open"></i> Project</h2>
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="project-form-side">
|
<div class="form-group">
|
||||||
{% include 'library/_project_form_side.html' with form=project_form %}
|
{{ project_form.title.errors }}
|
||||||
</aside>
|
<label for="{{ project_form.title.id_for_label }}">Title</label>
|
||||||
|
{{ project_form.title }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ project_form.summary.errors }}
|
||||||
|
<label for="{{ project_form.summary.id_for_label }}">Summary</label>
|
||||||
|
{{ project_form.summary }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ project_form.category.errors }}
|
||||||
|
<label for="{{ project_form.category.id_for_label }}">Category</label>
|
||||||
|
{{ project_form.category }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ project_form.description.errors }}
|
||||||
|
<label for="{{ project_form.description.id_for_label }}">Description</label>
|
||||||
|
{{ project_form.description }}
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="create-panel">
|
||||||
|
<h2 class="form-section-title"><i class="fas fa-image"></i> Thumbnail</h2>
|
||||||
|
<div class="upload-zone" data-upload="thumbnail">
|
||||||
|
<div class="drop-zone" role="button" tabindex="0">
|
||||||
|
<div class="drop-zone-inner">
|
||||||
|
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
|
||||||
|
<p class="drop-zone-text">Drag & drop an image</p>
|
||||||
|
<p class="drop-zone-sub">or click to browse</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="thumb-input" accept="image/*" hidden>
|
||||||
|
<div class="upload-preview" id="thumb-preview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="form-section-title" style="margin-top:16px;"><i class="fas fa-tags"></i> Tags</h2>
|
||||||
|
<div class="tag-editor">
|
||||||
|
<input type="text" id="tag-input" placeholder="Type a tag and press Enter… (e.g. content:threesome, species:dragon)" autocomplete="off">
|
||||||
|
<div class="tag-suggestions" id="tag-suggestions" hidden></div>
|
||||||
|
<div class="tag-chips" id="tag-chips"></div>
|
||||||
|
{{ project_form.tags }}
|
||||||
|
</div>
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Categories: loader, version, general, creator, content, species, non_version. Unprefixed tags go to General.</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -27,5 +69,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
{{ pending_uploads|json_script:'packs-pending-uploads' }}
|
||||||
|
{{ tag_categories|json_script:'packs-tag-categories' }}
|
||||||
<script src="{% static 'js/tags.js' %}"></script>
|
<script src="{% static 'js/tags.js' %}"></script>
|
||||||
|
<script src="{% static 'js/uploads.js' %}"></script>
|
||||||
|
<script src="{% static 'js/edit.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,11 +7,52 @@
|
|||||||
<h1><i class="fas fa-upload"></i> New version — {{ project.title }}</h1>
|
<h1><i class="fas fa-upload"></i> New version — {{ project.title }}</h1>
|
||||||
<p><a href="{% url 'library:project_detail' project.slug %}">← Back to project</a></p>
|
<p><a href="{% url 'library:project_detail' project.slug %}">← Back to project</a></p>
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data" class="card project-form">
|
<form method="post" class="card project-form" id="version-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% include 'library/_version_form_fields.html' with form=version_form %}
|
{% if version_form.non_field_errors %}
|
||||||
|
<div class="form-group">
|
||||||
|
<ul class="messages">
|
||||||
|
{% for error in version_form.non_field_errors %}<li class="error">{{ error }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ version_form.version_name.errors }}
|
||||||
|
<label for="{{ version_form.version_name.id_for_label }}">{{ version_form.version_name.label }}</label>
|
||||||
|
{{ version_form.version_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Version file</label>
|
||||||
|
<div class="upload-zone" data-upload="version">
|
||||||
|
<div class="drop-zone" role="button" tabindex="0">
|
||||||
|
<div class="drop-zone-inner">
|
||||||
|
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
|
||||||
|
<p class="drop-zone-text">Drag & drop the pack file</p>
|
||||||
|
<p class="drop-zone-sub">or click to browse</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="version-input" hidden>
|
||||||
|
<div class="upload-preview" id="version-preview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ version_form.changelog.errors }}
|
||||||
|
<label for="{{ version_form.changelog.id_for_label }}">{{ version_form.changelog.label }}</label>
|
||||||
|
{{ version_form.changelog }}
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary"><i class="fas fa-upload"></i> Upload version</button>
|
<button type="submit" class="btn btn-primary"><i class="fas fa-upload"></i> Upload version</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ pending_uploads|json_script:'packs-pending-uploads' }}
|
||||||
|
<script src="{% static 'js/uploads.js' %}"></script>
|
||||||
|
<script src="{% static 'js/version_upload.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user