Finished with Phase 2.6
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-08-04 05:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0007_version_animation_id_version_animation_manifest'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='version',
|
||||||
|
name='logical_manifest',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='version',
|
||||||
|
name='models_manifest',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -211,6 +211,8 @@ class Version(models.Model):
|
|||||||
mods_manifest = models.JSONField(default=dict, blank=True)
|
mods_manifest = models.JSONField(default=dict, blank=True)
|
||||||
animation_id = models.CharField(max_length=128, blank=True, default='', db_index=True)
|
animation_id = models.CharField(max_length=128, blank=True, default='', db_index=True)
|
||||||
animation_manifest = models.JSONField(default=dict, blank=True)
|
animation_manifest = models.JSONField(default=dict, blank=True)
|
||||||
|
models_manifest = models.JSONField(default=dict, blank=True)
|
||||||
|
logical_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:
|
||||||
@@ -277,6 +279,10 @@ class ProjectAsset(models.Model):
|
|||||||
def is_video(self):
|
def is_video(self):
|
||||||
return self.file.content_type.startswith('video/')
|
return self.file.content_type.startswith('video/')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_gif(self):
|
||||||
|
return self.file.content_type == 'image/gif'
|
||||||
|
|
||||||
|
|
||||||
class TempUpload(models.Model):
|
class TempUpload(models.Model):
|
||||||
"""J621-style draft upload. Stored under media/uploads/user_<id>/<uuid><ext>
|
"""J621-style draft upload. Stored under media/uploads/user_<id>/<uuid><ext>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import unittest
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@@ -1320,3 +1321,256 @@ class UGCAnimationTests(UGCMediaTestCase, UGCGatedTestCase):
|
|||||||
self.client.login(username='Alice', password='pw')
|
self.client.login(username='Alice', password='pw')
|
||||||
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
|
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
|
||||||
self.assertEqual(resp.status_code, 404)
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
def _geo_model_zip():
|
||||||
|
geo = {
|
||||||
|
'format_version': '1.12.0',
|
||||||
|
'afw_bone_textures': {'body': 'needsofnature:textures/entity/zombie/zombie.png'},
|
||||||
|
'minecraft:geometry': [{
|
||||||
|
'description': {'identifier': 'geometry.unknown', 'texture_width': 64, 'texture_height': 64},
|
||||||
|
'bones': [
|
||||||
|
{'name': 'root', 'pivot': [0, 0, 0]},
|
||||||
|
{'name': 'body', 'pivot': [0, 12, 0], 'cubes': [
|
||||||
|
{'origin': [-4, 0, -4], 'size': [8, 8, 8], 'uv': [0, 0]},
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
return _zip_bytes({
|
||||||
|
'pack.mcmeta': json.dumps({
|
||||||
|
'pack': {'pack_format': 88},
|
||||||
|
'animationframework': {'id': 'christmaspuffin:models_and_textures', 'name': 'Models',
|
||||||
|
'author': 'CP', 'version': '1.0'},
|
||||||
|
}),
|
||||||
|
'assets/animationframework/geckolib/models/entity/zombie.m.geo.json': json.dumps(geo),
|
||||||
|
'assets/needsofnature/textures/entity/zombie/zombie.png': b'PNG-DATA',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _logical_zip():
|
||||||
|
return _zip_bytes({
|
||||||
|
'pack.mcmeta': json.dumps({'pack': {'pack_format': 61}}),
|
||||||
|
'assets/usefulcum/items/potion_gender.json': json.dumps(
|
||||||
|
{'model': {'type': 'minecraft:model', 'model': 'usefulcum:item/potion_gender'}}),
|
||||||
|
'assets/usefulcum/textures/item/potion_gender.png': b'PNG',
|
||||||
|
'data/usefulcum/recipe/cum_combination.json': json.dumps({
|
||||||
|
'type': 'minecraft:crafting_shapeless',
|
||||||
|
'ingredients': ['minecraft:potion', 'minecraft:potion'],
|
||||||
|
'result': {'id': 'minecraft:glass_bottle', 'count': 2},
|
||||||
|
}),
|
||||||
|
'data/usefulcum/advancement/recipe/blaze_cum.json': json.dumps(
|
||||||
|
{'rewards': {'function': 'usefulcum:recipe/blaze_cum'}}),
|
||||||
|
'data/usefulcum/advancement/change_gender.json': json.dumps(
|
||||||
|
{'rewards': {'function': 'usefulcum:change_gender'}}),
|
||||||
|
'data/usefulcum/function/change_gender.mcfunction': b'# x',
|
||||||
|
'data/usefulcum/function/tick.mcfunction': b'# y',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class UGCContentTabsTests(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 _write_zip(self, data):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f:
|
||||||
|
f.write(data)
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
def _upload_version(self, slug, name, content):
|
||||||
|
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='application/zip')},
|
||||||
|
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
return self.client.post(
|
||||||
|
reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_read_pack_meta_without_animdefs(self):
|
||||||
|
from library.zips import read_pack_meta
|
||||||
|
path = self._write_zip(_geo_model_zip())
|
||||||
|
try:
|
||||||
|
meta = read_pack_meta(path)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertEqual(meta['animation_id'], 'christmaspuffin:models_and_textures')
|
||||||
|
self.assertEqual(meta['authors'], ['CP'])
|
||||||
|
|
||||||
|
def test_read_models_manifest(self):
|
||||||
|
from library.zips import read_models_manifest
|
||||||
|
path = self._write_zip(_geo_model_zip())
|
||||||
|
try:
|
||||||
|
manifest = read_models_manifest(path)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertIsNotNone(manifest)
|
||||||
|
model = manifest['models'][0]
|
||||||
|
self.assertEqual(model['name'], 'zombie.m.geo.json')
|
||||||
|
self.assertEqual(model['bones'], 2)
|
||||||
|
self.assertEqual(model['cubes'], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
model['bone_textures']['body'],
|
||||||
|
'assets/needsofnature/textures/entity/zombie/zombie.png',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_read_logical_manifest(self):
|
||||||
|
from library.zips import read_logical_manifest
|
||||||
|
path = self._write_zip(_logical_zip())
|
||||||
|
try:
|
||||||
|
manifest = read_logical_manifest(path)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertIsNotNone(manifest)
|
||||||
|
self.assertEqual(manifest['items'][0]['name'], 'usefulcum:potion_gender')
|
||||||
|
self.assertEqual(manifest['recipes'][0]['ingredients'], ['minecraft:potion'])
|
||||||
|
self.assertEqual(manifest['recipes'][0]['result'], 'minecraft:glass_bottle x2')
|
||||||
|
self.assertEqual(manifest['custom_recipes'][0]['name'], 'blaze_cum')
|
||||||
|
self.assertEqual(manifest['advancements'][0]['name'], 'change_gender')
|
||||||
|
self.assertEqual(len(manifest['functions']), 2)
|
||||||
|
|
||||||
|
def test_models_pack_gets_id_and_models_tab(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.assertEqual(version.animation_id, 'christmaspuffin:models_and_textures')
|
||||||
|
self.assertTrue(version.models_manifest)
|
||||||
|
self.assertFalse(version.animation_manifest)
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('library:project_detail', args=['models-pack']))
|
||||||
|
self.assertContains(resp, 'tab-models')
|
||||||
|
self.assertContains(resp, 'Render')
|
||||||
|
self.assertNotContains(resp, 'tab-animations')
|
||||||
|
|
||||||
|
def test_logical_pack_shows_only_logical_tab(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='logical-pack', title='Logical Pack', category='non_pack', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('logical-pack', 'pack.zip', _logical_zip())
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.assertTrue(version.logical_manifest)
|
||||||
|
self.assertFalse(version.models_manifest)
|
||||||
|
self.assertFalse(version.animation_manifest)
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('library:project_detail', args=['logical-pack']))
|
||||||
|
self.assertContains(resp, 'tab-logical')
|
||||||
|
self.assertNotContains(resp, 'tab-models')
|
||||||
|
self.assertNotContains(resp, 'tab-animations')
|
||||||
|
self.assertContains(resp, 'usefulcum:potion_gender')
|
||||||
|
|
||||||
|
def test_pack_asset_serves_member(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse(
|
||||||
|
'library:pack_asset',
|
||||||
|
args=['models-pack', version.pk, 'assets/needsofnature/textures/entity/zombie/zombie.png'],
|
||||||
|
))
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp['Content-Type'].split(';')[0], 'image/png')
|
||||||
|
self.assertEqual(resp.content, b'PNG-DATA')
|
||||||
|
|
||||||
|
def test_pack_asset_rejects_unknown_member(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse(
|
||||||
|
'library:pack_asset',
|
||||||
|
args=['models-pack', version.pk, 'assets/nope/nothing.png'],
|
||||||
|
))
|
||||||
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
|
def test_gallery_video_is_muted(self):
|
||||||
|
from library.storage import store_file
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='muted', title='Muted', category='mod', owner=self.alice,
|
||||||
|
)
|
||||||
|
index = store_file(
|
||||||
|
self.alice, 'asset',
|
||||||
|
SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm'),
|
||||||
|
self.project.pk, subdir='gallery',
|
||||||
|
)
|
||||||
|
ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('library:project_detail', args=['muted']))
|
||||||
|
self.assertContains(resp, 'muted')
|
||||||
|
|
||||||
|
|
||||||
|
def make_gif_bytes():
|
||||||
|
import PIL.Image
|
||||||
|
|
||||||
|
frames = [PIL.Image.new('RGB', (8, 8), c) for c in ('red', 'green')]
|
||||||
|
buf = io.BytesIO()
|
||||||
|
frames[0].save(buf, format='GIF', save_all=True, append_images=frames[1:], duration=100, loop=0)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class UGCThumbnailTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||||
|
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg required')
|
||||||
|
def test_video_and_gif_thumbnails(self):
|
||||||
|
User = get_user_model()
|
||||||
|
alice = User.objects.create_user(username='Alice', password='pw')
|
||||||
|
UserProfile.objects.get_or_create(user=alice)
|
||||||
|
project = Project.objects.create(slug='media', title='Media', category='mod', owner=alice)
|
||||||
|
from library.storage import store_file
|
||||||
|
|
||||||
|
gif = ProjectAsset.objects.create(
|
||||||
|
project=project, uploaded_by=alice,
|
||||||
|
file=store_file(
|
||||||
|
alice, 'asset',
|
||||||
|
SimpleUploadedFile('anim.gif', make_gif_bytes(), content_type='image/gif'),
|
||||||
|
project.pk, subdir='gallery',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
png = ProjectAsset.objects.create(
|
||||||
|
project=project, uploaded_by=alice,
|
||||||
|
file=store_file(
|
||||||
|
alice, 'asset',
|
||||||
|
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
|
||||||
|
project.pk, subdir='gallery',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.gate()
|
||||||
|
# Gif gets a cached JPEG thumbnail.
|
||||||
|
resp = self.client.get(reverse('library:asset_thumbnail', args=['media', gif.pk]))
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp['Content-Type'].split(';')[0], 'image/jpeg')
|
||||||
|
self.assertGreater(len(b''.join(resp.streaming_content)), 0)
|
||||||
|
# Cached on the second request.
|
||||||
|
resp2 = self.client.get(reverse('library:asset_thumbnail', args=['media', gif.pk]))
|
||||||
|
self.assertEqual(resp2.status_code, 200)
|
||||||
|
# A plain image is not thumbnailed → 404.
|
||||||
|
resp3 = self.client.get(reverse('library:asset_thumbnail', args=['media', png.pk]))
|
||||||
|
self.assertEqual(resp3.status_code, 404)
|
||||||
|
|
||||||
|
def test_gallery_grid_uses_thumbnail_for_gif(self):
|
||||||
|
User = get_user_model()
|
||||||
|
alice = User.objects.create_user(username='Alice', password='pw')
|
||||||
|
UserProfile.objects.get_or_create(user=alice)
|
||||||
|
project = Project.objects.create(slug='media', title='Media', category='mod', owner=alice)
|
||||||
|
from library.storage import store_file
|
||||||
|
|
||||||
|
ProjectAsset.objects.create(
|
||||||
|
project=project, uploaded_by=alice,
|
||||||
|
file=store_file(
|
||||||
|
alice, 'asset',
|
||||||
|
SimpleUploadedFile('anim.gif', make_gif_bytes(), content_type='image/gif'),
|
||||||
|
project.pk, subdir='gallery',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('library:project_detail', args=['media']))
|
||||||
|
self.assertContains(resp, '/thumb/')
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""ffmpeg thumbnail generation for gallery media (videos + gifs).
|
||||||
|
|
||||||
|
Grid previews use a small cached JPEG extracted by ffmpeg; the browser only
|
||||||
|
receives the real file when the full-screen modal opens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
FFMPEG = shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
MAX_WIDTH = 480
|
||||||
|
|
||||||
|
|
||||||
|
def thumbnail_stored_path(file_index):
|
||||||
|
"""Relative stored path of the cached thumbnail for an asset file."""
|
||||||
|
return f'thumbnails/{file_index.uuid}.jpg'
|
||||||
|
|
||||||
|
|
||||||
|
def thumbnail_ready(file_index):
|
||||||
|
return (Path(settings.MEDIA_ROOT) / thumbnail_stored_path(file_index)).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(file_index):
|
||||||
|
"""Generate (and cache) a JPEG thumbnail for a video/gif FileIndex.
|
||||||
|
|
||||||
|
Returns the relative stored path, or None when ffmpeg is unavailable or
|
||||||
|
extraction fails. Idempotent — a cached thumbnail is reused.
|
||||||
|
"""
|
||||||
|
if not FFMPEG or not file_index.stored_path:
|
||||||
|
return None
|
||||||
|
src = (Path(settings.MEDIA_ROOT) / file_index.stored_path).resolve()
|
||||||
|
if not src.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
out_rel = thumbnail_stored_path(file_index)
|
||||||
|
out = (Path(settings.MEDIA_ROOT) / out_rel).resolve()
|
||||||
|
if out.is_file():
|
||||||
|
return out_rel
|
||||||
|
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = out.with_suffix('.tmp.jpg')
|
||||||
|
cmd = [
|
||||||
|
FFMPEG, '-y', '-i', str(src),
|
||||||
|
'-frames:v', '1',
|
||||||
|
'-vf', f"scale='min({MAX_WIDTH},iw)':-2",
|
||||||
|
'-q:v', '4',
|
||||||
|
str(tmp),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return None
|
||||||
|
if result.returncode != 0 or not tmp.is_file():
|
||||||
|
return None
|
||||||
|
tmp.replace(out)
|
||||||
|
return out_rel
|
||||||
@@ -13,8 +13,10 @@ 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>/versions/<int:version_id>/asset/<path:asset_path>', views.pack_asset, name='pack_asset'),
|
||||||
path('packs/<slug:slug>/guide/<int:version_id>/<uuid:file_uuid>/', views.guide_doc, name='guide_doc'),
|
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>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
|
||||||
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'),
|
||||||
path('api/files/<uuid:file_id>/', views.file_request, name='file_request'),
|
path('api/files/<uuid:file_id>/', views.file_request, name='file_request'),
|
||||||
|
|||||||
+105
-18
@@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import posixpath
|
import posixpath
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -30,7 +32,14 @@ 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_animation_manifest, read_pack_mcmeta
|
from .zips import (
|
||||||
|
parse_mods_manifest,
|
||||||
|
read_animation_manifest,
|
||||||
|
read_logical_manifest,
|
||||||
|
read_models_manifest,
|
||||||
|
read_pack_meta,
|
||||||
|
read_pack_mcmeta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _open_indexed_file(file_index):
|
def _open_indexed_file(file_index):
|
||||||
@@ -317,12 +326,19 @@ def project_detail(request, slug):
|
|||||||
|
|
||||||
guide_docs = []
|
guide_docs = []
|
||||||
animation_manifest = None
|
animation_manifest = None
|
||||||
|
models_manifest = None
|
||||||
|
logical_manifest = None
|
||||||
|
mods_manifest = None
|
||||||
anim_stats = None
|
anim_stats = None
|
||||||
latest = versions.first()
|
latest = versions.first()
|
||||||
if project.category == 'guide' and latest is not None:
|
if project.category == 'guide' and latest is not None:
|
||||||
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
|
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
|
||||||
if latest is not None and latest.animation_manifest:
|
if latest is not None:
|
||||||
animation_manifest = latest.animation_manifest
|
animation_manifest = latest.animation_manifest or None
|
||||||
|
models_manifest = latest.models_manifest or None
|
||||||
|
logical_manifest = latest.logical_manifest or None
|
||||||
|
mods_manifest = latest.mods_manifest or None
|
||||||
|
if animation_manifest:
|
||||||
anims = animation_manifest.get('animations') or []
|
anims = animation_manifest.get('animations') or []
|
||||||
entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player')
|
entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player')
|
||||||
solo_count = sum(1 for a in anims if a.get('type') == 'Solo')
|
solo_count = sum(1 for a in anims if a.get('type') == 'Solo')
|
||||||
@@ -343,11 +359,37 @@ def project_detail(request, slug):
|
|||||||
'can_edit': can_edit,
|
'can_edit': can_edit,
|
||||||
'guide_docs': guide_docs,
|
'guide_docs': guide_docs,
|
||||||
'animation_manifest': animation_manifest,
|
'animation_manifest': animation_manifest,
|
||||||
|
'models_manifest': models_manifest,
|
||||||
|
'logical_manifest': logical_manifest,
|
||||||
|
'mods_manifest': mods_manifest,
|
||||||
'anim_stats': anim_stats,
|
'anim_stats': anim_stats,
|
||||||
}
|
}
|
||||||
return render(request, 'library/project_detail.html', context)
|
return render(request, 'library/project_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_asset(request, slug, version_id, asset_path):
|
||||||
|
"""Serve a named member from a stored version zip (gated), used for the
|
||||||
|
Models/Textures + Logical tabs and the model viewer."""
|
||||||
|
version = get_object_or_404(
|
||||||
|
Version.objects.select_related('project'),
|
||||||
|
pk=version_id, project__slug=slug,
|
||||||
|
)
|
||||||
|
if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'):
|
||||||
|
raise Http404
|
||||||
|
vf = (
|
||||||
|
version.files.select_related('file').filter(kind='release').first()
|
||||||
|
or version.files.select_related('file').first()
|
||||||
|
)
|
||||||
|
if vf is None:
|
||||||
|
raise Http404
|
||||||
|
with zipfile.ZipFile(_stored_path(vf.file)) as zf:
|
||||||
|
if asset_path not in zf.namelist():
|
||||||
|
raise Http404
|
||||||
|
data = zf.read(asset_path)
|
||||||
|
content_type = mimetypes.guess_type(asset_path)[0] or 'application/octet-stream'
|
||||||
|
return HttpResponse(data, content_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def api_packs_latest(request, namespace, pack_id):
|
def api_packs_latest(request, namespace, pack_id):
|
||||||
"""Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id)."""
|
"""Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id)."""
|
||||||
@@ -483,8 +525,8 @@ def _apply_content_tags(project, tag_names, actor):
|
|||||||
|
|
||||||
def _finalize_version(project, version_name, changelog, temp_uploads, actor):
|
def _finalize_version(project, version_name, changelog, temp_uploads, actor):
|
||||||
"""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 / animation
|
capturing category/content-specific metadata (pack.mcmeta, mods, animation,
|
||||||
manifest). Returns (version, missing_animation_id)."""
|
models, logical manifests). Returns (version, missing_animation_id)."""
|
||||||
version = Version.objects.create(
|
version = Version.objects.create(
|
||||||
project=project,
|
project=project,
|
||||||
version_name=version_name,
|
version_name=version_name,
|
||||||
@@ -493,7 +535,10 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
|
|||||||
pack_format = None
|
pack_format = None
|
||||||
pack_description = ''
|
pack_description = ''
|
||||||
mods_manifest = {}
|
mods_manifest = {}
|
||||||
|
animation_id = ''
|
||||||
animation_manifest = None
|
animation_manifest = None
|
||||||
|
models_manifest = None
|
||||||
|
logical_manifest = None
|
||||||
missing_animation_id = False
|
missing_animation_id = False
|
||||||
|
|
||||||
for temp in temp_uploads:
|
for temp in temp_uploads:
|
||||||
@@ -507,21 +552,31 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
|
|||||||
temp.save(update_fields=['status'])
|
temp.save(update_fields=['status'])
|
||||||
|
|
||||||
is_zip = (index.original_filename or '').lower().endswith('.zip')
|
is_zip = (index.original_filename or '').lower().endswith('.zip')
|
||||||
if project.category == 'non_pack' and is_zip and pack_format is None:
|
if not is_zip:
|
||||||
fmt, desc = read_pack_mcmeta(_stored_path(index))
|
continue
|
||||||
|
path = _stored_path(index)
|
||||||
|
|
||||||
|
if project.category == 'non_pack' and pack_format is None:
|
||||||
|
fmt, desc = read_pack_mcmeta(path)
|
||||||
if fmt is not None:
|
if fmt is not None:
|
||||||
pack_format = fmt
|
pack_format = fmt
|
||||||
pack_description = desc
|
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 not animation_id:
|
||||||
|
meta = read_pack_meta(path)
|
||||||
|
if meta and meta.get('animation_id'):
|
||||||
|
animation_id = meta['animation_id']
|
||||||
|
|
||||||
|
if animation_manifest is None:
|
||||||
|
animation_manifest = read_animation_manifest(path)
|
||||||
|
if models_manifest is None:
|
||||||
|
models_manifest = read_models_manifest(path)
|
||||||
|
if logical_manifest is None:
|
||||||
|
logical_manifest = read_logical_manifest(path)
|
||||||
|
if not mods_manifest:
|
||||||
|
manifest = parse_mods_manifest(path, index.original_filename)
|
||||||
if manifest.get('files'):
|
if manifest.get('files'):
|
||||||
mods_manifest = manifest
|
mods_manifest = manifest
|
||||||
if is_zip and animation_manifest is None:
|
|
||||||
manifest = read_animation_manifest(_stored_path(index))
|
|
||||||
if manifest is not None:
|
|
||||||
animation_manifest = manifest
|
|
||||||
if not manifest.get('animation_id'):
|
|
||||||
missing_animation_id = True
|
|
||||||
|
|
||||||
update_fields = []
|
update_fields = []
|
||||||
if pack_format is not None:
|
if pack_format is not None:
|
||||||
@@ -531,12 +586,20 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
|
|||||||
if mods_manifest:
|
if mods_manifest:
|
||||||
version.mods_manifest = mods_manifest
|
version.mods_manifest = mods_manifest
|
||||||
update_fields.append('mods_manifest')
|
update_fields.append('mods_manifest')
|
||||||
|
if animation_id:
|
||||||
|
version.animation_id = animation_id
|
||||||
|
update_fields.append('animation_id')
|
||||||
if animation_manifest is not None:
|
if animation_manifest is not None:
|
||||||
version.animation_manifest = animation_manifest
|
version.animation_manifest = animation_manifest
|
||||||
update_fields.append('animation_manifest')
|
update_fields.append('animation_manifest')
|
||||||
if animation_manifest.get('animation_id'):
|
if not animation_id:
|
||||||
version.animation_id = animation_manifest['animation_id']
|
missing_animation_id = True
|
||||||
update_fields.append('animation_id')
|
if models_manifest is not None:
|
||||||
|
version.models_manifest = models_manifest
|
||||||
|
update_fields.append('models_manifest')
|
||||||
|
if logical_manifest is not None:
|
||||||
|
version.logical_manifest = logical_manifest
|
||||||
|
update_fields.append('logical_manifest')
|
||||||
if update_fields:
|
if update_fields:
|
||||||
version.save(update_fields=update_fields)
|
version.save(update_fields=update_fields)
|
||||||
|
|
||||||
@@ -826,6 +889,30 @@ def asset_delete(request, slug, asset_id):
|
|||||||
return redirect('library:project_detail', slug=project.slug)
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
|
|
||||||
|
|
||||||
|
def asset_thumbnail(request, slug, asset_id):
|
||||||
|
"""Cached ffmpeg thumbnail for a gallery video/gif (grid preview only;
|
||||||
|
the modal loads the real file)."""
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
from django.http import FileResponse
|
||||||
|
|
||||||
|
from .thumbnails import generate_thumbnail
|
||||||
|
|
||||||
|
asset = get_object_or_404(
|
||||||
|
ProjectAsset.objects.select_related('file', 'project'),
|
||||||
|
pk=asset_id, project__slug=slug,
|
||||||
|
)
|
||||||
|
if not (asset.is_video or asset.is_gif):
|
||||||
|
raise Http404
|
||||||
|
stored = generate_thumbnail(asset.file)
|
||||||
|
if stored is None:
|
||||||
|
raise Http404
|
||||||
|
fh = open(_Path(settings.MEDIA_ROOT) / stored, 'rb')
|
||||||
|
response = FileResponse(fh, content_type='image/jpeg')
|
||||||
|
response['Cache-Control'] = 'public, max-age=86400'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def contributors(request, slug):
|
def contributors(request, slug):
|
||||||
project = get_object_or_404(Project, slug=slug)
|
project = get_object_or_404(Project, slug=slug)
|
||||||
|
|||||||
@@ -149,6 +149,232 @@ def _summarize_animdef(filename, data):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_pack_meta(path):
|
||||||
|
"""Read the animationframework block from pack.mcmeta regardless of whether
|
||||||
|
the pack has animdefs (models/textures packs). None when absent."""
|
||||||
|
zf = _open_zip(path)
|
||||||
|
if zf is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
mcmeta = _decode(zf.read('pack.mcmeta'))
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
af = mcmeta.get('animationframework')
|
||||||
|
if not af:
|
||||||
|
return None
|
||||||
|
authors = []
|
||||||
|
if af.get('author'):
|
||||||
|
authors = [a.strip() for a in str(af['author']).split(',') if a.strip()]
|
||||||
|
return {
|
||||||
|
'animation_id': af.get('id') or None,
|
||||||
|
'name': str(af.get('name') or ''),
|
||||||
|
'version': str(af.get('version') or ''),
|
||||||
|
'authors': authors,
|
||||||
|
'description': str(af.get('description') or ''),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
zf.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_to_member(resource):
|
||||||
|
"""needsofnature:textures/entity/zombie/zombie.png → assets/needsofnature/..."""
|
||||||
|
if ':' in resource:
|
||||||
|
ns, _, path = resource.partition(':')
|
||||||
|
return f'assets/{ns}/{path}'
|
||||||
|
if resource.startswith('assets/'):
|
||||||
|
return resource
|
||||||
|
return f'assets/{resource}'
|
||||||
|
|
||||||
|
|
||||||
|
def read_models_manifest(path):
|
||||||
|
"""Build the Models/Textures manifest from GeckoLib geo models.
|
||||||
|
|
||||||
|
Returns None when the pack has no geo models. Each model records its geo
|
||||||
|
filename, identifier, bone/cube counts, and the afw_bone_textures map
|
||||||
|
(bone → texture zip member) so the viewer can texture it."""
|
||||||
|
zf = _open_zip(path)
|
||||||
|
if zf is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
geo_names = sorted(
|
||||||
|
n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json')
|
||||||
|
)
|
||||||
|
if not geo_names:
|
||||||
|
return None
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for name in geo_names:
|
||||||
|
try:
|
||||||
|
data = _decode(zf.read(name))
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
bone_textures = {}
|
||||||
|
for bone, tex in (data.get('afw_bone_textures') or {}).items():
|
||||||
|
member = _resource_to_member(str(tex))
|
||||||
|
if member in names:
|
||||||
|
bone_textures[str(bone)] = member
|
||||||
|
bone_count = 0
|
||||||
|
cube_count = 0
|
||||||
|
identifier = ''
|
||||||
|
for geom in data.get('minecraft:geometry') or []:
|
||||||
|
identifier = identifier or (geom.get('description') or {}).get('identifier', '')
|
||||||
|
for bone in geom.get('bones') or []:
|
||||||
|
bone_count += 1
|
||||||
|
cube_count += len(bone.get('cubes') or [])
|
||||||
|
models.append({
|
||||||
|
'name': name.rsplit('/', 1)[-1],
|
||||||
|
'member': name,
|
||||||
|
'identifier': identifier,
|
||||||
|
'bones': bone_count,
|
||||||
|
'cubes': cube_count,
|
||||||
|
'bone_textures': bone_textures,
|
||||||
|
'textures': sorted(set(bone_textures.values())),
|
||||||
|
})
|
||||||
|
|
||||||
|
textures = sorted(n for n in names if n.startswith('assets/') and n.endswith('.png'))
|
||||||
|
return {'models': models, 'textures': textures}
|
||||||
|
finally:
|
||||||
|
zf.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _item_name_from_member(member):
|
||||||
|
parts = member.split('/')
|
||||||
|
try:
|
||||||
|
idx = parts.index('items')
|
||||||
|
ns = parts[idx - 1]
|
||||||
|
name = parts[idx + 1]
|
||||||
|
if name.endswith('.json'):
|
||||||
|
name = name[:-5]
|
||||||
|
return f'{ns}:{name}'
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
def _ingredient_names(recipe):
|
||||||
|
if isinstance(recipe.get('key'), dict):
|
||||||
|
raw = list(recipe['key'].values())
|
||||||
|
elif 'ingredients' in recipe:
|
||||||
|
raw = recipe['ingredients']
|
||||||
|
else:
|
||||||
|
raw = []
|
||||||
|
names = set()
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
item = item.get('id')
|
||||||
|
if item:
|
||||||
|
names.add(str(item))
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def _result_name(recipe):
|
||||||
|
res = recipe.get('result')
|
||||||
|
if isinstance(res, dict):
|
||||||
|
rid = res.get('id') or res.get('item') or ''
|
||||||
|
count = res.get('count')
|
||||||
|
if count and int(count) != 1:
|
||||||
|
return f'{rid} x{count}'
|
||||||
|
return str(rid)
|
||||||
|
return str(res) if res else ''
|
||||||
|
|
||||||
|
|
||||||
|
def read_logical_manifest(path):
|
||||||
|
"""Build the Logical manifest outlining what a datapack adds (items,
|
||||||
|
recipes, custom crafting, advancements, functions, NoN extras)."""
|
||||||
|
zf = _open_zip(path)
|
||||||
|
if zf is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
|
||||||
|
def _dir_members(subdir, suffix):
|
||||||
|
"""data/<ns>/<subdir>/<file> or assets/<ns>/<subdir>/<file> (4 parts)."""
|
||||||
|
out = []
|
||||||
|
for n in names:
|
||||||
|
parts = n.split('/')
|
||||||
|
if len(parts) == 4 and parts[2] == subdir and n.endswith(suffix):
|
||||||
|
out.append(n)
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
items = _dir_members('items', '.json')
|
||||||
|
recipes = _dir_members('recipe', '.json')
|
||||||
|
functions = sorted(
|
||||||
|
n for n in names
|
||||||
|
if n.startswith('data/') and '/function/' in n and n.endswith('.mcfunction')
|
||||||
|
)
|
||||||
|
liquid_gains = [n for n in names if '/non_liquid_gains/' in n]
|
||||||
|
entity_profiles = [n for n in names if '/non_entity_profiles/' in n]
|
||||||
|
trinkets = [n for n in names if '/trinkets/' in n]
|
||||||
|
|
||||||
|
if not any([items, recipes, functions, liquid_gains, entity_profiles, trinkets]):
|
||||||
|
return None
|
||||||
|
|
||||||
|
advancement_members = sorted(
|
||||||
|
n for n in names
|
||||||
|
if n.startswith('data/') and '/advancement/' in n and n.endswith('.json')
|
||||||
|
)
|
||||||
|
custom_recipes = [n for n in advancement_members if '/advancement/recipe/' in n]
|
||||||
|
advancements_plain = [n for n in advancement_members if '/advancement/recipe/' not in n]
|
||||||
|
|
||||||
|
item_list = []
|
||||||
|
for member in items:
|
||||||
|
name = _item_name_from_member(member)
|
||||||
|
ns, _, base = name.partition(':')
|
||||||
|
tex_member = f'assets/{ns}/textures/item/{base}.png'
|
||||||
|
item_list.append({
|
||||||
|
'name': name,
|
||||||
|
'texture': tex_member if tex_member in names else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
recipe_list = []
|
||||||
|
for member in recipes:
|
||||||
|
base = member.rsplit('/', 1)[-1]
|
||||||
|
disabled = '.disabled' in base
|
||||||
|
clean = base.replace('.json', '').replace('.disabled', '')
|
||||||
|
try:
|
||||||
|
data = _decode(zf.read(member))
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
recipe_list.append({
|
||||||
|
'name': clean,
|
||||||
|
'type': (data.get('type') or '').rsplit(':', 1)[-1],
|
||||||
|
'ingredients': _ingredient_names(data),
|
||||||
|
'result': _result_name(data),
|
||||||
|
'disabled': disabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
custom_recipes_out = []
|
||||||
|
advancements_out = []
|
||||||
|
for member in advancement_members:
|
||||||
|
try:
|
||||||
|
data = _decode(zf.read(member))
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
reward = (data.get('rewards') or {}).get('function')
|
||||||
|
name = member.rsplit('/', 1)[-1][:-5]
|
||||||
|
if member in custom_recipes:
|
||||||
|
custom_recipes_out.append({'name': name, 'reward': reward})
|
||||||
|
else:
|
||||||
|
advancements_out.append({'name': name, 'reward': reward})
|
||||||
|
|
||||||
|
function_list = sorted(n for n in functions if '/recipe/' not in n)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'items': item_list,
|
||||||
|
'recipes': recipe_list,
|
||||||
|
'custom_recipes': custom_recipes_out,
|
||||||
|
'advancements': advancements_out,
|
||||||
|
'functions': function_list,
|
||||||
|
'custom_functions': len(functions) - len(function_list),
|
||||||
|
'liquid_gains': liquid_gains,
|
||||||
|
'entity_profiles': entity_profiles,
|
||||||
|
'trinkets': trinkets,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
zf.close()
|
||||||
|
|
||||||
|
|
||||||
def read_animation_manifest(path):
|
def read_animation_manifest(path):
|
||||||
"""Build the animation manifest for a NoN animation pack.
|
"""Build the animation manifest for a NoN animation pack.
|
||||||
|
|
||||||
|
|||||||
@@ -2258,18 +2258,28 @@ a.deletelink {
|
|||||||
background: var(--md-sys-color-surface-variant);
|
background: var(--md-sys-color-surface-variant);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
}
|
}
|
||||||
.gallery-item img,
|
.gallery-item img,
|
||||||
.gallery-item video {
|
.gallery-item video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
max-height: 260px;
|
|
||||||
}
|
}
|
||||||
.gallery-item figcaption {
|
.gallery-item figcaption {
|
||||||
padding: 8px 12px;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.gallery-delete {
|
.gallery-delete {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -2284,10 +2294,31 @@ a.deletelink {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
.gallery-delete:hover {
|
.gallery-delete:hover {
|
||||||
background: var(--md-sys-color-error);
|
background: var(--md-sys-color-error);
|
||||||
}
|
}
|
||||||
|
.gallery-expand {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.gallery-item:hover .gallery-expand {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.empty-hint {
|
.empty-hint {
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
@@ -3028,3 +3059,205 @@ a.deletelink {
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== Modals (gallery + model viewer) ========== */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.modal-overlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
width: fit-content;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: 92vh;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--md-sys-color-error);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.media-modal-content {
|
||||||
|
padding: 8px;
|
||||||
|
min-width: min(420px, 92vw);
|
||||||
|
}
|
||||||
|
.modal-media {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-media img,
|
||||||
|
.modal-media video {
|
||||||
|
display: block;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 88vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.modal-media img {
|
||||||
|
min-width: 320px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.modal-caption {
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.model-modal-content {
|
||||||
|
width: min(720px, 94vw);
|
||||||
|
}
|
||||||
|
.model-modal-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
#model-viewer-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 480px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Models/Textures tab ========== */
|
||||||
|
.model-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.model-card {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
background: var(--md-sys-color-surface-container-low);
|
||||||
|
}
|
||||||
|
.model-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.model-card-head strong {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.model-meta {
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
.model-textures {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.model-textures img {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--md-sys-color-outline-variant);
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
}
|
||||||
|
.model-textures.strip {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.model-textures.strip img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.models-textures-title {
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Logical tab ========== */
|
||||||
|
.logical-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.logical-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.logical-item img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.logical-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.logical-list li {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
}
|
||||||
|
.logical-list code,
|
||||||
|
.logical-tags code {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.logical-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.logical-tags code {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.badge-disabled {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 6px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--md-sys-color-error-container, #fdecea);
|
||||||
|
color: var(--md-sys-color-on-error-container, #7f1d1d);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Minimal GeckoLib/Blockbench geo.json viewer (Three.js) for the Models tab.
|
||||||
|
// Renders cubes with the standard "box UV" layout and the NoN `afw_bone_textures`
|
||||||
|
// map (bone → texture), textures served through the gated pack_asset endpoint.
|
||||||
|
(function () {
|
||||||
|
let renderer = null;
|
||||||
|
let scene = null;
|
||||||
|
let camera = null;
|
||||||
|
let meshRoot = null;
|
||||||
|
let rafId = null;
|
||||||
|
|
||||||
|
function assetUrl(baseUrl, member) {
|
||||||
|
return baseUrl.replace('ASSET', member);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boneChildren(map, name, rootName) {
|
||||||
|
return Object.keys(map).filter(
|
||||||
|
b => map[b].parent === name || (name === rootName && !map[b].parent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minecraft "box UV" face rects (u, v, w, h) in texture pixels.
|
||||||
|
function boxUVRects(uv, size) {
|
||||||
|
const u = uv[0], v = uv[1];
|
||||||
|
const x = size[0], y = size[1], z = size[2];
|
||||||
|
return {
|
||||||
|
top: [u + z, v, x, z],
|
||||||
|
bottom: [u + z + x, v, x, z],
|
||||||
|
north: [u + z, v + z, x, y],
|
||||||
|
south: [u + z + x, v + z, x, y],
|
||||||
|
east: [u, v + z, z, y],
|
||||||
|
west: [u + z + x + z, v + z, z, y],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignBoxUVs(geometry, uv, tw, th) {
|
||||||
|
// Three.js BoxGeometry groups: 0=px(east) 1=nx(west) 2=py(top) 3=ny(bottom) 4=pz(south) 5=nz(north)
|
||||||
|
const rects = boxUVRects(uv, [
|
||||||
|
geometry.parameters.width,
|
||||||
|
geometry.parameters.height,
|
||||||
|
geometry.parameters.depth,
|
||||||
|
]);
|
||||||
|
const order = ['east', 'west', 'top', 'bottom', 'south', 'north'];
|
||||||
|
const uvs = geometry.attributes.uv;
|
||||||
|
const positions = geometry.attributes.position;
|
||||||
|
for (let g = 0; g < geometry.groups.length; g++) {
|
||||||
|
const face = order[g];
|
||||||
|
if (!face) break;
|
||||||
|
const [ru, rv, rw, rh] = rects[face];
|
||||||
|
const u0 = ru / tw;
|
||||||
|
const v0 = 1 - (rv + rh) / th; // flip: image v=0 is the top
|
||||||
|
const u1 = (ru + rw) / tw;
|
||||||
|
const v1 = 1 - rv / th;
|
||||||
|
const start = geometry.groups[g].start;
|
||||||
|
const count = geometry.groups[g].count;
|
||||||
|
for (let i = start; i < start + count; i++) {
|
||||||
|
// Keep U/V in order (positions already give 0..1 corners per face).
|
||||||
|
const px = positions.getX(i), py = positions.getY(i), pz = positions.getZ(i);
|
||||||
|
let U, V;
|
||||||
|
if (face === 'east' || face === 'west') { U = (pz + 0.5) * (u1 - u0) + u0; V = (py + 0.5) * (v1 - v0) + v0; }
|
||||||
|
else if (face === 'top' || face === 'bottom') { U = (px + 0.5) * (u1 - u0) + u0; V = (pz + 0.5) * (v1 - v0) + v0; }
|
||||||
|
else { U = (px + 0.5) * (u1 - u0) + u0; V = (py + 0.5) * (v1 - v0) + v0; }
|
||||||
|
uvs.setXY(i, U, V);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uvs.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBone(map, name, textures, tw, th, parentGroup) {
|
||||||
|
const bone = map[name];
|
||||||
|
const group = new THREE.Group();
|
||||||
|
if (bone.pivot) group.position.set(bone.pivot[0], bone.pivot[1], bone.pivot[2]);
|
||||||
|
if (bone.rotation) {
|
||||||
|
const r = bone.rotation;
|
||||||
|
group.rotation.order = 'XYZ';
|
||||||
|
group.rotation.set(r[0] * Math.PI / 180, r[1] * Math.PI / 180, r[2] * Math.PI / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
const texMember = textures[name];
|
||||||
|
let material;
|
||||||
|
if (texMember) {
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
const texture = loader.load(assetUrl(window.__PACK_ASSET_BASE__ || '', texMember));
|
||||||
|
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
|
material = new THREE.MeshStandardMaterial({ map: texture, roughness: 0.9, metalness: 0.0 });
|
||||||
|
} else {
|
||||||
|
material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cube of bone.cubes || []) {
|
||||||
|
const size = new THREE.Vector3(cube.size[0], cube.size[1], cube.size[2]);
|
||||||
|
const geometry = new THREE.BoxGeometry(size.x, size.y, size.z);
|
||||||
|
assignBoxUVs(geometry, cube.uv || [0, 0], tw, th);
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
const cx = cube.origin[0] + size.x / 2;
|
||||||
|
const cy = cube.origin[1] + size.y / 2;
|
||||||
|
const cz = cube.origin[2] + size.z / 2;
|
||||||
|
mesh.position.set(cx, cy, cz);
|
||||||
|
if (cube.rotation) {
|
||||||
|
const r = cube.rotation;
|
||||||
|
mesh.rotation.set(r[0] * Math.PI / 180, r[1] * Math.PI / 180, r[2] * Math.PI / 180);
|
||||||
|
}
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of boneChildren(map, name, null)) {
|
||||||
|
buildBone(map, child, textures, tw, th, group);
|
||||||
|
}
|
||||||
|
parentGroup.add(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeObject(obj) {
|
||||||
|
obj.traverse((node) => {
|
||||||
|
if (node.geometry) node.geometry.dispose();
|
||||||
|
if (node.material) {
|
||||||
|
if (node.material.map) node.material.map.dispose();
|
||||||
|
node.material.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(member, container, baseUrl) {
|
||||||
|
window.__PACK_ASSET_BASE__ = baseUrl;
|
||||||
|
if (renderer) dispose();
|
||||||
|
const width = container.clientWidth || 480;
|
||||||
|
const height = container.clientHeight || 480;
|
||||||
|
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x1e1e2e);
|
||||||
|
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 2000);
|
||||||
|
camera.position.set(0, 20, 60);
|
||||||
|
camera.lookAt(0, 12, 0);
|
||||||
|
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||||
|
key.position.set(30, 60, 30);
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||||
|
fill.position.set(-30, 20, -30);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
meshRoot = new THREE.Group();
|
||||||
|
scene.add(meshRoot);
|
||||||
|
|
||||||
|
const url = assetUrl(baseUrl, member);
|
||||||
|
fetch(url, { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(r => { if (!r.ok) throw new Error('http'); return r.json(); })
|
||||||
|
.then(geo => {
|
||||||
|
const geometry = (geo['minecraft:geometry'] || [])[0];
|
||||||
|
if (!geometry) throw new Error('no geometry');
|
||||||
|
const desc = geometry.description || {};
|
||||||
|
const tw = desc.texture_width || 64;
|
||||||
|
const th = desc.texture_height || 64;
|
||||||
|
const textures = geo['afw_bone_textures'] || {};
|
||||||
|
const map = {};
|
||||||
|
(geometry.bones || []).forEach(b => { map[b.name] = b; });
|
||||||
|
const roots = Object.keys(map).filter(b => !map[b].parent);
|
||||||
|
roots.forEach(root => buildBone(map, root, textures, tw, th, meshRoot));
|
||||||
|
|
||||||
|
// Fit camera to bounds.
|
||||||
|
const box = new THREE.Box3().setFromObject(meshRoot);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const radius = Math.max(size.x, size.y, size.z) / 2 || 10;
|
||||||
|
meshRoot.position.sub(center);
|
||||||
|
camera.position.set(radius * 2.2, radius * 1.6, radius * 2.6);
|
||||||
|
camera.near = radius / 10;
|
||||||
|
camera.far = radius * 40;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
container.innerHTML = '<p class="empty-hint">Could not load this model.</p>';
|
||||||
|
});
|
||||||
|
|
||||||
|
let isDragging = false;
|
||||||
|
let lastX = 0, lastY = 0;
|
||||||
|
renderer.domElement.addEventListener('mousedown', (e) => {
|
||||||
|
isDragging = true; lastX = e.clientX; lastY = e.clientY;
|
||||||
|
});
|
||||||
|
window.addEventListener('mouseup', () => { isDragging = false; });
|
||||||
|
window.addEventListener('mousemove', (e) => {
|
||||||
|
if (!isDragging || !meshRoot) return;
|
||||||
|
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
meshRoot.rotation.y += dx * 0.01;
|
||||||
|
meshRoot.rotation.x += dy * 0.01;
|
||||||
|
});
|
||||||
|
renderer.domElement.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
camera.position.multiplyScalar(e.deltaY > 0 ? 1.06 : 0.94);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
rafId = requestAnimationFrame(animate);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||||
|
if (renderer) {
|
||||||
|
if (meshRoot) disposeObject(meshRoot);
|
||||||
|
renderer.dispose();
|
||||||
|
if (renderer.domElement && renderer.domElement.parentNode) {
|
||||||
|
renderer.domElement.parentNode.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer = null;
|
||||||
|
scene = null;
|
||||||
|
camera = null;
|
||||||
|
meshRoot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PacksModelViewer = { render: render, dispose: dispose };
|
||||||
|
})();
|
||||||
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -65,7 +65,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="versions" role="tab">Versions</button>
|
<button class="tab-btn" data-tab="versions" role="tab">Versions</button>
|
||||||
{% if animation_manifest %}<button class="tab-btn" data-tab="animations" role="tab">Animations</button>{% endif %}
|
{% if animation_manifest %}<button class="tab-btn" data-tab="animations" role="tab">Animations</button>{% endif %}
|
||||||
{% if project.category == 'modpack' %}<button class="tab-btn" data-tab="mods" role="tab">Mods</button>{% endif %}
|
{% if models_manifest %}<button class="tab-btn" data-tab="models" role="tab">Models/Textures</button>{% endif %}
|
||||||
|
{% if logical_manifest %}<button class="tab-btn" data-tab="logical" role="tab">Logical</button>{% endif %}
|
||||||
|
{% if mods_manifest %}<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>
|
||||||
|
|
||||||
@@ -229,24 +231,129 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if project.category == 'modpack' %}
|
{% if models_manifest %}
|
||||||
|
<section class="tab-panel" id="tab-models">
|
||||||
|
<div class="card">
|
||||||
|
<h2><i class="fas fa-cube"></i> Models & Textures</h2>
|
||||||
|
<div class="model-grid">
|
||||||
|
{% for model in models_manifest.models %}
|
||||||
|
<div class="model-card">
|
||||||
|
<div class="model-card-head">
|
||||||
|
<strong>{{ model.name }}</strong>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm model-render-btn"
|
||||||
|
data-member="{{ model.member }}" data-name="{{ model.name }}"
|
||||||
|
data-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}"><i class="fas fa-cube"></i> Render</button>
|
||||||
|
</div>
|
||||||
|
<p class="model-meta">{{ model.bones }} bones · {{ model.cubes }} cubes</p>
|
||||||
|
<div class="model-textures">
|
||||||
|
{% for member in model.textures %}
|
||||||
|
<img src="{% url 'library:pack_asset' project.slug versions.0.pk member %}" alt="{{ member }}" loading="lazy" title="{{ member }}">
|
||||||
|
{% empty %}
|
||||||
|
<span class="empty-hint">No textures</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if models_manifest.textures %}
|
||||||
|
<h3 class="models-textures-title">Pack textures</h3>
|
||||||
|
<div class="model-textures strip">
|
||||||
|
{% for texture in models_manifest.textures %}
|
||||||
|
<img src="{% url 'library:pack_asset' project.slug versions.0.pk texture %}" alt="{{ texture }}" loading="lazy" title="{{ texture }}">
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if logical_manifest %}
|
||||||
|
<section class="tab-panel" id="tab-logical">
|
||||||
|
<div class="card">
|
||||||
|
<h2><i class="fas fa-box"></i> What this pack adds</h2>
|
||||||
|
|
||||||
|
{% if logical_manifest.items %}
|
||||||
|
<h3>Items ({{ logical_manifest.items|length }})</h3>
|
||||||
|
<div class="logical-items">
|
||||||
|
{% for item in logical_manifest.items %}
|
||||||
|
<span class="logical-item">
|
||||||
|
{% if item.texture %}
|
||||||
|
<img src="{% url 'library:pack_asset' project.slug versions.0.pk item.texture %}" alt="" loading="lazy">
|
||||||
|
{% endif %}
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if logical_manifest.recipes %}
|
||||||
|
<h3>Recipes ({{ logical_manifest.recipes|length }})</h3>
|
||||||
|
<ul class="logical-list">
|
||||||
|
{% for r in logical_manifest.recipes %}
|
||||||
|
<li>
|
||||||
|
{% if r.disabled %}<span class="badge-disabled">disabled</span>{% endif %}
|
||||||
|
<code>{{ r.ingredients|join:' + ' }}</code> → <code>{{ r.result }}</code>
|
||||||
|
{% if r.type %}<small>({{ r.type }})</small>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if logical_manifest.custom_recipes %}
|
||||||
|
<h3>Custom crafting</h3>
|
||||||
|
<ul class="logical-list">
|
||||||
|
{% for r in logical_manifest.custom_recipes %}
|
||||||
|
<li>{{ r.name }} → <code>{{ r.reward }}</code></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if logical_manifest.advancements %}
|
||||||
|
<h3>Advancements</h3>
|
||||||
|
<ul class="logical-list">
|
||||||
|
{% for a in logical_manifest.advancements %}
|
||||||
|
<li>{{ a.name }}{% if a.reward %} → <code>{{ a.reward }}</code>{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if logical_manifest.functions %}
|
||||||
|
<h3>Functions ({{ logical_manifest.functions|length }}{% if logical_manifest.custom_functions %} + {{ logical_manifest.custom_functions }} custom{% endif %})</h3>
|
||||||
|
<div class="logical-tags">{% for f in logical_manifest.functions %}<code>{{ f }}</code>{% endfor %}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if logical_manifest.liquid_gains %}
|
||||||
|
<h3>Liquids</h3>
|
||||||
|
<div class="logical-tags">{% for l in logical_manifest.liquid_gains %}<code>{{ l }}</code>{% endfor %}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if logical_manifest.entity_profiles %}
|
||||||
|
<h3>Entity profiles</h3>
|
||||||
|
<div class="logical-tags">{% for e in logical_manifest.entity_profiles %}<code>{{ e }}</code>{% endfor %}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if logical_manifest.trinkets %}
|
||||||
|
<h3>Trinkets</h3>
|
||||||
|
<div class="logical-tags">{% for t in logical_manifest.trinkets %}<code>{{ t }}</code>{% endfor %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if mods_manifest %}
|
||||||
<section class="tab-panel" id="tab-mods">
|
<section class="tab-panel" id="tab-mods">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><i class="fas fa-cubes"></i> Mods</h2>
|
<h2><i class="fas fa-cubes"></i> Mods</h2>
|
||||||
{% for version in versions %}
|
{% if mods_manifest.files %}
|
||||||
{% if version.mods_manifest.files %}
|
<span class="mods-source-badge">{{ mods_manifest.source }}</span>
|
||||||
<div class="mods-version">
|
<ul class="mods-list">
|
||||||
<h3>{{ version.version_name }} <span class="mods-source-badge">{{ version.mods_manifest.source }}</span></h3>
|
{% for mod in mods_manifest.files %}
|
||||||
<ul class="mods-list">
|
<li><i class="fas fa-cube"></i> {{ mod.name }}</li>
|
||||||
{% for mod in version.mods_manifest.files %}
|
{% endfor %}
|
||||||
<li><i class="fas fa-cube"></i> {{ mod.name }}</li>
|
</ul>
|
||||||
{% endfor %}
|
{% else %}
|
||||||
</ul>
|
<p class="empty-hint">No mods detected in the uploaded pack.</p>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
{% empty %}
|
|
||||||
<p class="empty-hint">No mods detected in the uploaded packs.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -256,12 +363,13 @@
|
|||||||
<h2><i class="fas fa-images"></i> Gallery</h2>
|
<h2><i class="fas fa-images"></i> Gallery</h2>
|
||||||
<div class="gallery-grid">
|
<div class="gallery-grid">
|
||||||
{% for asset in assets %}
|
{% for asset in assets %}
|
||||||
<figure class="gallery-item">
|
<figure class="gallery-item" data-media-url="{{ asset.file_url }}" data-media-type="{% if asset.is_video %}video{% else %}image{% endif %}" data-caption="{{ asset.caption }}">
|
||||||
{% if asset.is_video %}
|
{% if asset.is_video or asset.is_gif %}
|
||||||
<video controls preload="metadata" src="{{ asset.file_url }}"></video>
|
<img class="gallery-thumb" src="{% url 'library:asset_thumbnail' project.slug asset.pk %}" alt="{{ asset.caption }}" loading="lazy">
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ asset.file_url }}" target="_blank"><img src="{{ asset.file_url }}" alt="{{ asset.caption }}"></a>
|
<img src="{{ asset.file_url }}" alt="{{ asset.caption }}" loading="lazy">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="gallery-expand" title="Open fullscreen"><i class="fas fa-expand"></i></span>
|
||||||
{% if asset.caption %}<figcaption>{{ asset.caption }}</figcaption>{% endif %}
|
{% if asset.caption %}<figcaption>{{ asset.caption }}</figcaption>{% endif %}
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<a href="{% url 'library:asset_delete' project.slug asset.pk %}" class="gallery-delete" title="Delete media"><i class="fas fa-trash"></i></a>
|
<a href="{% url 'library:asset_delete' project.slug asset.pk %}" class="gallery-delete" title="Delete media"><i class="fas fa-trash"></i></a>
|
||||||
@@ -273,9 +381,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="gallery-modal" hidden>
|
||||||
|
<div class="modal-content media-modal-content">
|
||||||
|
<button type="button" class="modal-close" data-close-modal aria-label="Close"><i class="fas fa-times"></i></button>
|
||||||
|
<div class="modal-media" id="gallery-modal-media"></div>
|
||||||
|
<p class="modal-caption" id="gallery-modal-caption"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="model-modal" hidden>
|
||||||
|
<div class="modal-content model-modal-content">
|
||||||
|
<button type="button" class="modal-close" data-close-modal aria-label="Close"><i class="fas fa-times"></i></button>
|
||||||
|
<h3 class="model-modal-title" id="model-modal-title"></h3>
|
||||||
|
<div id="model-viewer-container"></div>
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Drag to rotate · scroll to zoom</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
{% if models_manifest %}
|
||||||
|
<script src="{% static 'vendor/three.min.js' %}"></script>
|
||||||
|
<script src="{% static 'js/model_viewer.js' %}"></script>
|
||||||
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const buttons = document.querySelectorAll('.tab-btn');
|
const buttons = document.querySelectorAll('.tab-btn');
|
||||||
@@ -332,6 +461,67 @@
|
|||||||
const first = guideBtns[0];
|
const first = guideBtns[0];
|
||||||
if (first) loadGuide(first.dataset.url);
|
if (first) loadGuide(first.dataset.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full-screen gallery modal (images/gifs/videos; videos muted).
|
||||||
|
const galleryModal = document.getElementById('gallery-modal');
|
||||||
|
if (galleryModal) {
|
||||||
|
const mediaEl = document.getElementById('gallery-modal-media');
|
||||||
|
const captionEl = document.getElementById('gallery-modal-caption');
|
||||||
|
document.querySelectorAll('.gallery-item[data-media-url]').forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.gallery-delete')) return;
|
||||||
|
const url = item.dataset.mediaUrl;
|
||||||
|
const type = item.dataset.mediaType;
|
||||||
|
mediaEl.innerHTML = type === 'video'
|
||||||
|
? '<video controls muted playsinline autoplay src="' + url + '"></video>'
|
||||||
|
: '<img src="' + url + '" alt="">';
|
||||||
|
captionEl.textContent = item.dataset.caption || '';
|
||||||
|
galleryModal.hidden = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close buttons for modals.
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const modal = btn.closest('.modal-overlay');
|
||||||
|
if (modal) {
|
||||||
|
modal.hidden = true;
|
||||||
|
const media = modal.querySelector('.modal-media');
|
||||||
|
if (media) media.innerHTML = '';
|
||||||
|
if (modal.id === 'model-modal') {
|
||||||
|
const viewer = document.getElementById('model-viewer-container');
|
||||||
|
if (viewer) viewer.innerHTML = '';
|
||||||
|
if (window.PacksModelViewer) window.PacksModelViewer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(modal => {
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.hidden = true;
|
||||||
|
if (modal.id === 'model-modal' && window.PacksModelViewer) window.PacksModelViewer.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Model renderer (Three.js showcase).
|
||||||
|
if (window.PacksModelViewer) {
|
||||||
|
document.querySelectorAll('.model-render-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.getElementById('model-modal-title').textContent = btn.dataset.name || '';
|
||||||
|
const container = document.getElementById('model-viewer-container');
|
||||||
|
container.innerHTML = '';
|
||||||
|
window.PacksModelViewer.render(
|
||||||
|
btn.dataset.member,
|
||||||
|
container,
|
||||||
|
btn.dataset.baseUrl
|
||||||
|
);
|
||||||
|
document.getElementById('model-modal').hidden = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user