temporal upload. parcial Phase 2 implementation
This commit is contained in:
@@ -6,3 +6,4 @@ Packs_DB
|
|||||||
nonpacks/staticfiles/
|
nonpacks/staticfiles/
|
||||||
nonpacks/media/
|
nonpacks/media/
|
||||||
*.log
|
*.log
|
||||||
|
AGENTS/
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-08-04 04:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0005_remove_version_file_versionfile'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='version',
|
||||||
|
name='mods_manifest',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='version',
|
||||||
|
name='pack_description',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='version',
|
||||||
|
name='pack_format',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -206,6 +206,9 @@ class Version(models.Model):
|
|||||||
version_name = models.CharField(max_length=64)
|
version_name = models.CharField(max_length=64)
|
||||||
changelog = models.TextField(blank=True, default='')
|
changelog = models.TextField(blank=True, default='')
|
||||||
downloads = models.PositiveIntegerField(default=0)
|
downloads = models.PositiveIntegerField(default=0)
|
||||||
|
pack_format = models.IntegerField(null=True, blank=True)
|
||||||
|
pack_description = models.TextField(blank=True, default='')
|
||||||
|
mods_manifest = models.JSONField(default=dict, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
@@ -964,3 +967,178 @@ class UGCApiTests(UGCMediaTestCase, UGCGatedTestCase):
|
|||||||
resp = self.client.get(reverse('library:api_users_autocomplete'), {'q': 'Ali'})
|
resp = self.client.get(reverse('library:api_users_autocomplete'), {'q': 'Ali'})
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
self.assertJSONEqual(resp.content, [{'username': 'Alice'}])
|
self.assertJSONEqual(resp.content, [{'username': 'Alice'}])
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_bytes(entries):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w') as zf:
|
||||||
|
for name, content in entries.items():
|
||||||
|
zf.writestr(name, content)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_zip():
|
||||||
|
return _zip_bytes({
|
||||||
|
'pack.mcmeta': json.dumps({
|
||||||
|
'pack': {'pack_format': 64, 'description': 'A test pack'},
|
||||||
|
'animationframework': {'id': 'jakebreath:test'},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class UGCZipParsingTests(TestCase):
|
||||||
|
def _write_zip(self, data, suffix='.zip'):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
||||||
|
f.write(data)
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
def test_read_pack_mcmeta(self):
|
||||||
|
from library.zips import read_pack_mcmeta
|
||||||
|
path = self._write_zip(_pack_zip())
|
||||||
|
try:
|
||||||
|
fmt, desc = read_pack_mcmeta(path)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertEqual(fmt, 64)
|
||||||
|
self.assertEqual(desc, 'A test pack')
|
||||||
|
|
||||||
|
def test_read_pack_mcmeta_missing(self):
|
||||||
|
from library.zips import read_pack_mcmeta
|
||||||
|
path = self._write_zip(_zip_bytes({'assets/x.txt': 'x'}))
|
||||||
|
try:
|
||||||
|
fmt, desc = read_pack_mcmeta(path)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertIsNone(fmt)
|
||||||
|
self.assertEqual(desc, '')
|
||||||
|
|
||||||
|
def test_parse_mods_folder(self):
|
||||||
|
from library.zips import parse_mods_manifest
|
||||||
|
path = self._write_zip(_zip_bytes({
|
||||||
|
'minecraft/mods/a.jar': 'x', 'minecraft/mods/b.jar': 'y',
|
||||||
|
'pack.mcmeta': '{}',
|
||||||
|
}))
|
||||||
|
try:
|
||||||
|
manifest = parse_mods_manifest(path, 'pack.zip')
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertEqual(manifest['source'], 'folder')
|
||||||
|
self.assertEqual([m['name'] for m in manifest['files']], ['a.jar', 'b.jar'])
|
||||||
|
|
||||||
|
def test_parse_mods_mrpack(self):
|
||||||
|
from library.zips import parse_mods_manifest
|
||||||
|
path = self._write_zip(_zip_bytes({
|
||||||
|
'modrinth.index.json': json.dumps({
|
||||||
|
'dependencies': [
|
||||||
|
{'project_id': 'abc', 'file_name': 'mod-a.jar', 'dependency_type': 'required'},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}), suffix='.mrpack')
|
||||||
|
try:
|
||||||
|
manifest = parse_mods_manifest(path, 'pack.mrpack')
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertEqual(manifest['source'], 'modrinth')
|
||||||
|
self.assertEqual(manifest['files'][0]['name'], 'mod-a.jar')
|
||||||
|
|
||||||
|
def test_parse_mods_curseforge(self):
|
||||||
|
from library.zips import parse_mods_manifest
|
||||||
|
path = self._write_zip(_zip_bytes({
|
||||||
|
'manifest.json': json.dumps({
|
||||||
|
'files': [{'projectID': 123, 'fileID': 456, 'fileName': 'cool-mod.jar'}],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
try:
|
||||||
|
manifest = parse_mods_manifest(path, 'pack.zip')
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertEqual(manifest['source'], 'curseforge')
|
||||||
|
self.assertEqual(manifest['files'][0]['name'], 'cool-mod.jar')
|
||||||
|
|
||||||
|
|
||||||
|
class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.alice = User.objects.create_user(username='Alice', password='pw')
|
||||||
|
UserProfile.objects.get_or_create(user=self.alice)
|
||||||
|
|
||||||
|
def _upload_version(self, name, content, content_type='application/zip'):
|
||||||
|
self.gate()
|
||||||
|
self.client.login(username='Alice', password='pw')
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse('library:api_upload_temp'),
|
||||||
|
{'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type=content_type)},
|
||||||
|
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
def _upload_version_form(self, version_name='1.0.0'):
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse('library:version_upload', args=[self.project.slug]),
|
||||||
|
{'version_name': version_name},
|
||||||
|
)
|
||||||
|
self.assertRedirects(resp, reverse('library:project_detail', args=[self.project.slug]))
|
||||||
|
|
||||||
|
def test_non_pack_captures_pack_mcmeta(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='non-pack', title='NoN Pack', category='non_pack', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('pack.zip', _pack_zip())
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.assertEqual(version.pack_format, 64)
|
||||||
|
self.assertEqual(version.pack_description, 'A test pack')
|
||||||
|
|
||||||
|
def test_modpack_captures_mods_manifest(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='modpack', title='Modpack', category='modpack', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('pack.zip', _zip_bytes({'minecraft/mods/a.jar': 'x'}))
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.assertEqual(version.mods_manifest['source'], 'folder')
|
||||||
|
self.assertEqual(version.mods_manifest['files'][0]['name'], 'a.jar')
|
||||||
|
|
||||||
|
def test_guide_uploads_md_and_zip_and_renders(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='guide', title='Guide', category='guide', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('README.md', b'# Title\n\nBody text.', content_type='text/markdown')
|
||||||
|
self._upload_version('assets.zip', _zip_bytes({'data/x.json': '{}'}))
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.assertEqual(
|
||||||
|
set(version.files.values_list('kind', flat=True)), {'markdown', 'release'}
|
||||||
|
)
|
||||||
|
md_vf = version.files.get(kind='markdown')
|
||||||
|
# guide_doc endpoint renders the markdown server-side.
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, md_vf.file.uuid]))
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertContains(resp, '<h1>Title</h1>', html=True)
|
||||||
|
# Detail page exposes the guide switcher.
|
||||||
|
resp = self.client.get(reverse('library:project_detail', args=['guide']))
|
||||||
|
self.assertContains(resp, 'guide-switcher')
|
||||||
|
self.assertContains(resp, 'README.md')
|
||||||
|
|
||||||
|
def test_guide_doc_rejects_non_markdown(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='guide', title='Guide', category='guide', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('pack.zip', _zip_bytes({'data/x.json': '{}'}))
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
vf = version.files.get(kind='release')
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid]))
|
||||||
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
|
def test_regular_category_has_no_extra_metadata(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='mod', title='Mod', category='mod', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('pack.zip', _pack_zip())
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.assertIsNone(version.pack_format)
|
||||||
|
self.assertEqual(version.mods_manifest, {})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ urlpatterns = [
|
|||||||
path('packs/<slug:slug>/versions/upload/', views.version_upload, name='version_upload'),
|
path('packs/<slug:slug>/versions/upload/', views.version_upload, name='version_upload'),
|
||||||
path('packs/<slug:slug>/versions/<int:version_id>/download/', views.version_download, name='version_download'),
|
path('packs/<slug:slug>/versions/<int:version_id>/download/', views.version_download, name='version_download'),
|
||||||
path('packs/<slug:slug>/versions/<int:version_id>/files/<uuid:file_uuid>/download/', views.version_file_download, name='version_file_download'),
|
path('packs/<slug:slug>/versions/<int:version_id>/files/<uuid:file_uuid>/download/', views.version_file_download, name='version_file_download'),
|
||||||
|
path('packs/<slug:slug>/guide/<int:version_id>/<uuid:file_uuid>/', views.guide_doc, name='guide_doc'),
|
||||||
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
|
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
|
||||||
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
|
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
|
||||||
path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'),
|
path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from common.markdown import render_markdown
|
||||||
from .forms import ContributorForm, ProjectForm, VersionForm
|
from .forms import ContributorForm, ProjectForm, VersionForm
|
||||||
from .models import (
|
from .models import (
|
||||||
FileIndex,
|
FileIndex,
|
||||||
@@ -29,6 +30,7 @@ from .models import (
|
|||||||
slugify_tag,
|
slugify_tag,
|
||||||
)
|
)
|
||||||
from .storage import delete_file_index, move_file_index, store_temp_file
|
from .storage import delete_file_index, move_file_index, store_temp_file
|
||||||
|
from .zips import parse_mods_manifest, read_pack_mcmeta
|
||||||
|
|
||||||
|
|
||||||
def _open_indexed_file(file_index):
|
def _open_indexed_file(file_index):
|
||||||
@@ -312,16 +314,43 @@ def project_detail(request, slug):
|
|||||||
assets = project.assets.select_related('file').all()
|
assets = project.assets.select_related('file').all()
|
||||||
tags = project.tag_links.select_related('tag__category').all()
|
tags = project.tag_links.select_related('tag__category').all()
|
||||||
can_edit = project.can_edit(request.user)
|
can_edit = project.can_edit(request.user)
|
||||||
|
|
||||||
|
guide_docs = []
|
||||||
|
if project.category == 'guide':
|
||||||
|
latest = versions.first()
|
||||||
|
if latest is not None:
|
||||||
|
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'project': project,
|
'project': project,
|
||||||
'versions': versions,
|
'versions': versions,
|
||||||
'assets': assets,
|
'assets': assets,
|
||||||
'tags': tags,
|
'tags': tags,
|
||||||
'can_edit': can_edit,
|
'can_edit': can_edit,
|
||||||
|
'guide_docs': guide_docs,
|
||||||
}
|
}
|
||||||
return render(request, 'library/project_detail.html', context)
|
return render(request, 'library/project_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def guide_doc(request, slug, version_id, file_uuid):
|
||||||
|
"""Render one markdown guide document server-side (used by the switcher)."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if not vf.is_markdown:
|
||||||
|
raise Http404
|
||||||
|
fh = _open_indexed_file(vf.file)
|
||||||
|
try:
|
||||||
|
text = fh.read().decode('utf-8', 'replace')
|
||||||
|
finally:
|
||||||
|
fh.close()
|
||||||
|
return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
|
||||||
|
|
||||||
|
|
||||||
DRAFT_KEYS = (
|
DRAFT_KEYS = (
|
||||||
'title', 'summary', 'category', 'description',
|
'title', 'summary', 'category', 'description',
|
||||||
'tags', 'version_name', 'changelog',
|
'tags', 'version_name', 'changelog',
|
||||||
@@ -376,13 +405,23 @@ def _classify_version_file(file_index, category):
|
|||||||
return 'release'
|
return 'release'
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_path(file_index):
|
||||||
|
"""Resolve an indexed file's stored_path to an absolute filesystem path."""
|
||||||
|
return Path(settings.MEDIA_ROOT) / file_index.stored_path
|
||||||
|
|
||||||
|
|
||||||
def _finalize_version(project, version_name, changelog, temp_uploads):
|
def _finalize_version(project, version_name, changelog, temp_uploads):
|
||||||
"""Adopt pending 'version' temp uploads into a new Version as VersionFiles."""
|
"""Adopt pending 'version' temp uploads into a new Version as VersionFiles,
|
||||||
|
capturing category-specific metadata (pack.mcmeta / mods manifest)."""
|
||||||
version = Version.objects.create(
|
version = Version.objects.create(
|
||||||
project=project,
|
project=project,
|
||||||
version_name=version_name,
|
version_name=version_name,
|
||||||
changelog=changelog,
|
changelog=changelog,
|
||||||
)
|
)
|
||||||
|
pack_format = None
|
||||||
|
pack_description = ''
|
||||||
|
mods_manifest = {}
|
||||||
|
|
||||||
for temp in temp_uploads:
|
for temp in temp_uploads:
|
||||||
index = _adopt_temp(temp, project.pk, 'versions', 'version')
|
index = _adopt_temp(temp, project.pk, 'versions', 'version')
|
||||||
VersionFile.objects.create(
|
VersionFile.objects.create(
|
||||||
@@ -392,6 +431,28 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
|
|||||||
)
|
)
|
||||||
temp.status = 'used'
|
temp.status = 'used'
|
||||||
temp.save(update_fields=['status'])
|
temp.save(update_fields=['status'])
|
||||||
|
|
||||||
|
is_zip = (index.original_filename or '').lower().endswith('.zip')
|
||||||
|
if project.category == 'non_pack' and is_zip and pack_format is None:
|
||||||
|
fmt, desc = read_pack_mcmeta(_stored_path(index))
|
||||||
|
if fmt is not None:
|
||||||
|
pack_format = fmt
|
||||||
|
pack_description = desc
|
||||||
|
elif project.category == 'modpack' and is_zip and not mods_manifest:
|
||||||
|
manifest = parse_mods_manifest(_stored_path(index), index.original_filename)
|
||||||
|
if manifest.get('files'):
|
||||||
|
mods_manifest = manifest
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
if pack_format is not None:
|
||||||
|
version.pack_format = pack_format
|
||||||
|
version.pack_description = pack_description
|
||||||
|
update_fields += ['pack_format', 'pack_description']
|
||||||
|
if mods_manifest:
|
||||||
|
version.mods_manifest = mods_manifest
|
||||||
|
update_fields.append('mods_manifest')
|
||||||
|
if update_fields:
|
||||||
|
version.save(update_fields=update_fields)
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Zip introspection helpers for category-specific version metadata.
|
||||||
|
|
||||||
|
Parsed once at upload time and stored on the Version row, so the detail page
|
||||||
|
never re-reads large archives. All functions take the on-disk path of an
|
||||||
|
adopted file (under MEDIA_ROOT) and return plain dicts/values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
def _open_zip(path):
|
||||||
|
"""Return a ZipFile for the path (None when it's not a zip)."""
|
||||||
|
try:
|
||||||
|
return zipfile.ZipFile(path)
|
||||||
|
except (zipfile.BadZipFile, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_pack_mcmeta(path):
|
||||||
|
"""Extract the NoN-relevant data from a datapack's pack.mcmeta.
|
||||||
|
|
||||||
|
Returns (pack_format, description) or (None, '').
|
||||||
|
"""
|
||||||
|
zf = _open_zip(path)
|
||||||
|
if zf is None:
|
||||||
|
return None, ''
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
data = json.loads(zf.read('pack.mcmeta').decode('utf-8', 'replace'))
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
return None, ''
|
||||||
|
pack = data.get('pack') or {}
|
||||||
|
try:
|
||||||
|
pack_format = int(pack.get('pack_format'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pack_format = None
|
||||||
|
description = pack.get('description', '') or ''
|
||||||
|
if isinstance(description, (dict, list)):
|
||||||
|
description = json.dumps(description)
|
||||||
|
return pack_format, str(description)
|
||||||
|
finally:
|
||||||
|
zf.close()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mods_manifest(path, filename=''):
|
||||||
|
"""Inspect a modpack archive and build a manifest for the Mods tab.
|
||||||
|
|
||||||
|
- Modrinth .mrpack: modrinth.index.json → dependencies
|
||||||
|
- Curseforge modpack: manifest.json → files array
|
||||||
|
- Classic folder layout: minecraft/mods/*.jar
|
||||||
|
Returns a dict: {source, files: [...]}.
|
||||||
|
"""
|
||||||
|
zf = _open_zip(path)
|
||||||
|
if zf is None:
|
||||||
|
return {'source': 'unknown', 'files': []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
|
||||||
|
if filename.lower().endswith('.mrpack') or 'modrinth.index.json' in names:
|
||||||
|
try:
|
||||||
|
index = json.loads(zf.read('modrinth.index.json').decode('utf-8', 'replace'))
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
index = {}
|
||||||
|
deps = []
|
||||||
|
for dep in index.get('dependencies') or []:
|
||||||
|
deps.append({
|
||||||
|
'name': dep.get('file_name') or dep.get('project_id', 'unknown'),
|
||||||
|
'version_id': dep.get('version_id'),
|
||||||
|
'type': dep.get('dependency_type', ''),
|
||||||
|
})
|
||||||
|
return {'source': 'modrinth', 'files': deps}
|
||||||
|
|
||||||
|
if 'manifest.json' in names:
|
||||||
|
try:
|
||||||
|
manifest = json.loads(zf.read('manifest.json').decode('utf-8', 'replace'))
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
manifest = {}
|
||||||
|
files = []
|
||||||
|
for entry in manifest.get('files') or []:
|
||||||
|
files.append({
|
||||||
|
'name': entry.get('fileName', entry.get('projectID', 'unknown')),
|
||||||
|
'project_id': entry.get('projectID'),
|
||||||
|
'file_id': entry.get('fileID'),
|
||||||
|
})
|
||||||
|
if files:
|
||||||
|
return {'source': 'curseforge', 'files': files}
|
||||||
|
|
||||||
|
mods = []
|
||||||
|
prefix = 'minecraft/mods/'
|
||||||
|
for name in sorted(names):
|
||||||
|
if name.startswith(prefix) and not name.endswith('/'):
|
||||||
|
mods.append({'name': name[len(prefix):]})
|
||||||
|
if mods:
|
||||||
|
return {'source': 'folder', 'files': mods}
|
||||||
|
|
||||||
|
return {'source': 'unknown', 'files': []}
|
||||||
|
finally:
|
||||||
|
zf.close()
|
||||||
@@ -2773,3 +2773,89 @@ a.deletelink {
|
|||||||
.file-caption::placeholder {
|
.file-caption::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== Category-specific (Phase 2) ========== */
|
||||||
|
.pack-format-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pack-description {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guide switcher */
|
||||||
|
.guide-switcher {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.guide-switch-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.guide-switch-btn:hover {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
border-color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
.guide-switch-btn.active {
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
border-color: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
}
|
||||||
|
.guide-content {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mods tab */
|
||||||
|
.mods-version {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
.mods-version h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.mods-source-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.mods-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.mods-list li {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,11 +27,11 @@
|
|||||||
<label for="{{ project_form.category.id_for_label }}">Category</label>
|
<label for="{{ project_form.category.id_for_label }}">Category</label>
|
||||||
{{ project_form.category }}
|
{{ project_form.category }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" id="description-group">
|
||||||
{{ project_form.description.errors }}
|
{{ project_form.description.errors }}
|
||||||
<label for="{{ project_form.description.id_for_label }}">Description</label>
|
<label for="{{ project_form.description.id_for_label }}">Description</label>
|
||||||
{{ project_form.description }}
|
{{ project_form.description }}
|
||||||
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more.</p>
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more. Guides don't use this field.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -116,4 +116,14 @@
|
|||||||
<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/uploads.js' %}"></script>
|
||||||
<script src="{% static 'js/create.js' %}"></script>
|
<script src="{% static 'js/create.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const cat = document.getElementById('id_category');
|
||||||
|
const group = document.getElementById('description-group');
|
||||||
|
if (!cat || !group) return;
|
||||||
|
function toggle() { group.style.display = cat.value === 'guide' ? 'none' : ''; }
|
||||||
|
cat.addEventListener('change', toggle);
|
||||||
|
toggle();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -60,14 +60,33 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="tab-bar" role="tablist">
|
<div class="tab-bar" role="tablist">
|
||||||
<button class="tab-btn active" data-tab="description" role="tab">Description</button>
|
<button class="tab-btn active" data-tab="{% if project.category == 'guide' %}guide{% else %}description{% endif %}" role="tab">
|
||||||
|
{% if project.category == 'guide' %}Guide{% else %}Description{% endif %}
|
||||||
|
</button>
|
||||||
<button class="tab-btn" data-tab="versions" role="tab">Versions</button>
|
<button class="tab-btn" data-tab="versions" role="tab">Versions</button>
|
||||||
|
{% if project.category == 'modpack' %}<button class="tab-btn" data-tab="mods" role="tab">Mods</button>{% endif %}
|
||||||
<button class="tab-btn" data-tab="gallery" role="tab">Gallery</button>
|
<button class="tab-btn" data-tab="gallery" role="tab">Gallery</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="tab-panel active" id="tab-description">
|
<section class="tab-panel active" id="tab-{% if project.category == 'guide' %}guide{% else %}description{% endif %}">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{% if project.description %}
|
{% if project.category == 'guide' %}
|
||||||
|
<div class="guide-docs">
|
||||||
|
{% if guide_docs %}
|
||||||
|
<div class="guide-switcher" role="tablist">
|
||||||
|
{% for doc in guide_docs %}
|
||||||
|
<button type="button" class="guide-switch-btn{% if forloop.first %} active{% endif %}"
|
||||||
|
data-url="{% url 'library:guide_doc' project.slug doc.version.pk doc.file.uuid %}">{{ doc.filename }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="markdown-body guide-content" id="guide-content">
|
||||||
|
<p class="empty-hint">Loading…</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-hint">No guide documents uploaded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif project.description %}
|
||||||
<div class="markdown-body project-description">{{ project.description|markdown }}</div>
|
<div class="markdown-body project-description">{{ project.description|markdown }}</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-hint">No description yet.</p>
|
<p class="empty-hint">No description yet.</p>
|
||||||
@@ -84,10 +103,14 @@
|
|||||||
<div class="version-name-row">
|
<div class="version-name-row">
|
||||||
<strong class="version-name">{{ version.version_name }}</strong>
|
<strong class="version-name">{{ version.version_name }}</strong>
|
||||||
{% if forloop.first %}<span class="latest-badge">latest</span>{% endif %}
|
{% if forloop.first %}<span class="latest-badge">latest</span>{% endif %}
|
||||||
|
{% if version.pack_format %}<span class="pack-format-badge">pack format {{ version.pack_format }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<span class="version-date"><i class="fas fa-clock"></i> {{ version.created_at|date:"M j, Y" }}</span>
|
<span class="version-date"><i class="fas fa-clock"></i> {{ version.created_at|date:"M j, Y" }}</span>
|
||||||
<span class="version-downloads"><i class="fas fa-download"></i> {{ version.downloads }}</span>
|
<span class="version-downloads"><i class="fas fa-download"></i> {{ version.downloads }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if version.pack_description %}
|
||||||
|
<p class="pack-description"><i class="fas fa-info-circle"></i> {{ version.pack_description }}</p>
|
||||||
|
{% endif %}
|
||||||
{% if version.changelog %}
|
{% if version.changelog %}
|
||||||
<div class="markdown-body version-changelog">{{ version.changelog|markdown }}</div>
|
<div class="markdown-body version-changelog">{{ version.changelog|markdown }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -96,6 +119,8 @@
|
|||||||
<div class="version-file">
|
<div class="version-file">
|
||||||
{% if vf.is_png %}
|
{% if vf.is_png %}
|
||||||
<img class="version-file-thumb" src="{% url 'library:file_request' vf.file.uuid %}" alt="{{ vf.filename }}">
|
<img class="version-file-thumb" src="{% url 'library:file_request' vf.file.uuid %}" alt="{{ vf.filename }}">
|
||||||
|
{% elif vf.is_markdown %}
|
||||||
|
<i class="fas fa-file-lines version-file-icon"></i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="fas fa-file-archive version-file-icon"></i>
|
<i class="fas fa-file-archive version-file-icon"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -113,6 +138,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if project.category == 'modpack' %}
|
||||||
|
<section class="tab-panel" id="tab-mods">
|
||||||
|
<div class="card">
|
||||||
|
<h2><i class="fas fa-cubes"></i> Mods</h2>
|
||||||
|
{% for version in versions %}
|
||||||
|
{% if version.mods_manifest.files %}
|
||||||
|
<div class="mods-version">
|
||||||
|
<h3>{{ version.version_name }} <span class="mods-source-badge">{{ version.mods_manifest.source }}</span></h3>
|
||||||
|
<ul class="mods-list">
|
||||||
|
{% for mod in version.mods_manifest.files %}
|
||||||
|
<li><i class="fas fa-cube"></i> {{ mod.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% empty %}
|
||||||
|
<p class="empty-hint">No mods detected in the uploaded packs.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="tab-panel" id="tab-gallery">
|
<section class="tab-panel" id="tab-gallery">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><i class="fas fa-images"></i> Gallery</h2>
|
<h2><i class="fas fa-images"></i> Gallery</h2>
|
||||||
@@ -172,6 +219,28 @@
|
|||||||
xhr.send();
|
xhr.send();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Guide document file-switcher (fetches + renders the selected .md).
|
||||||
|
const guideContent = document.getElementById('guide-content');
|
||||||
|
if (guideContent) {
|
||||||
|
const guideBtns = document.querySelectorAll('.guide-switch-btn');
|
||||||
|
function loadGuide(url) {
|
||||||
|
guideContent.innerHTML = '<p class="empty-hint">Loading…</p>';
|
||||||
|
fetch(url, { headers: { 'Accept': 'text/html' } })
|
||||||
|
.then(r => { if (!r.ok) throw new Error('http'); return r.text(); })
|
||||||
|
.then(html => { guideContent.innerHTML = html; })
|
||||||
|
.catch(() => { guideContent.innerHTML = '<p class="empty-hint">Could not load this document.</p>'; });
|
||||||
|
}
|
||||||
|
guideBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
guideBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
loadGuide(btn.dataset.url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const first = guideBtns[0];
|
||||||
|
if (first) loadGuide(first.dataset.url);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@
|
|||||||
<label for="{{ project_form.category.id_for_label }}">Category</label>
|
<label for="{{ project_form.category.id_for_label }}">Category</label>
|
||||||
{{ project_form.category }}
|
{{ project_form.category }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" id="description-group">
|
||||||
{{ project_form.description.errors }}
|
{{ project_form.description.errors }}
|
||||||
<label for="{{ project_form.description.id_for_label }}">Description</label>
|
<label for="{{ project_form.description.id_for_label }}">Description</label>
|
||||||
{{ project_form.description }}
|
{{ project_form.description }}
|
||||||
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more.</p>
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more. Guides don't use this field.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -74,4 +74,14 @@
|
|||||||
<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/uploads.js' %}"></script>
|
||||||
<script src="{% static 'js/edit.js' %}"></script>
|
<script src="{% static 'js/edit.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const cat = document.getElementById('id_category');
|
||||||
|
const group = document.getElementById('description-group');
|
||||||
|
if (!cat || !group) return;
|
||||||
|
function toggle() { group.style.display = cat.value === 'guide' ? 'none' : ''; }
|
||||||
|
cat.addEventListener('change', toggle);
|
||||||
|
toggle();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user