backup for Phase 2.5

This commit is contained in:
2026-08-04 00:34:02 -05:00
parent a81e31bf1a
commit 8c13c5e5ec
9 changed files with 745 additions and 11 deletions
@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-08-04 04:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0006_version_mods_manifest_version_pack_description_and_more'),
]
operations = [
migrations.AddField(
model_name='version',
name='animation_id',
field=models.CharField(blank=True, db_index=True, default='', max_length=128),
),
migrations.AddField(
model_name='version',
name='animation_manifest',
field=models.JSONField(blank=True, default=dict),
),
]
+2
View File
@@ -209,6 +209,8 @@ class Version(models.Model):
pack_format = models.IntegerField(null=True, blank=True) pack_format = models.IntegerField(null=True, blank=True)
pack_description = models.TextField(blank=True, default='') pack_description = models.TextField(blank=True, default='')
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_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:
+178
View File
@@ -1142,3 +1142,181 @@ class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase):
version = self.project.versions.first() version = self.project.versions.first()
self.assertIsNone(version.pack_format) self.assertIsNone(version.pack_format)
self.assertEqual(version.mods_manifest, {}) self.assertEqual(version.mods_manifest, {})
def _animation_pack_zip(with_id=True, entity_x_player=False, problem=True):
actor_a = {
'label': 'actor1', 'entity_types': ['minecraft:player'],
'actor_tags': ['gender.male'], 'activity': 'active', 'injector': 'V',
}
actor_b = {
'label': 'actor2',
'entity_types': ['minecraft:zombie'] if entity_x_player else ['minecraft:player'],
'actor_tags': [] if entity_x_player else ['gender.female'],
'activity': 'passive', 'receiver': True,
}
content_tags = ['missionary'] + (['bugged'] if problem else [])
af = {
'id': 'jakebreath:testpack', 'name': 'Test Pack', 'author': 'Alice, Bob',
'version': '1.0.0', 'description': 'A test pack.',
} if with_id else {'name': 'Test Pack', 'author': 'Alice'}
return _zip_bytes({
'pack.mcmeta': json.dumps({'pack': {'pack_format': 64}, 'animationframework': af}),
'data/jakebreath/afw_animdefs/ground.json': json.dumps({
'display_name': 'Ground', 'content_tags': content_tags,
'actors': [actor_a, actor_b],
'stages': [{'stage': 1, 'loop': True, 'cycle_seconds': 1.0},
{'stage': 2, 'loop': False, 'non_peak': True}],
}),
})
def _bom_pack_zip():
mcmeta = b'\xef\xbb\xbf' + json.dumps({
'pack': {'pack_format': 64},
'animationframework': {'id': 'needsofnature:default', 'name': 'Default', 'author': 'NoN Team'},
}).encode()
return _zip_bytes({
'pack.mcmeta': mcmeta,
'data/needsofnature/afw_animdefs/solo.json': json.dumps({
'actors': [{'label': 'actor1', 'entity_types': ['minecraft:player'], 'activity': 'passive'}],
'content_tags': ['solo'],
'stages': [{'stage': 1, 'loop': True}],
}),
})
class UGCAnimationTests(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_animation_manifest_player_x_player(self):
from library.zips import read_animation_manifest
path = self._write_zip(_animation_pack_zip())
try:
manifest = read_animation_manifest(path)
finally:
os.unlink(path)
self.assertEqual(manifest['animation_id'], 'jakebreath:testpack')
self.assertEqual(manifest['authors'], ['Alice', 'Bob'])
anim = manifest['animations'][0]
self.assertEqual(anim['name'], 'Ground')
self.assertEqual(anim['type'], 'Pair')
self.assertEqual(anim['content_tags'], ['missionary', 'bugged'])
self.assertEqual(anim['problem_tags'], ['bugged'])
self.assertEqual(anim['actors'][0]['injector'], 'V')
self.assertEqual(anim['actors'][0]['injector_name'], 'Vaginal')
self.assertEqual(anim['actors'][1]['gender'], 'female')
self.assertEqual(anim['stages'][1]['climax'], True)
def test_read_animation_manifest_entity_x_player(self):
from library.zips import read_animation_manifest
path = self._write_zip(_animation_pack_zip(entity_x_player=True))
try:
manifest = read_animation_manifest(path)
finally:
os.unlink(path)
anim = manifest['animations'][0]
self.assertEqual(anim['type'], 'Entity x Player')
self.assertEqual(anim['actors'][1]['entity'], 'Zombie')
self.assertEqual(anim['entity_names'], ['Zombie'])
def test_read_animation_manifest_bom_mcmeta(self):
from library.zips import read_animation_manifest, read_pack_mcmeta
path = self._write_zip(_bom_pack_zip())
try:
fmt, desc = read_pack_mcmeta(path)
manifest = read_animation_manifest(path)
finally:
os.unlink(path)
self.assertEqual(fmt, 64)
self.assertEqual(manifest['animation_id'], 'needsofnature:default')
self.assertEqual(manifest['animations'][0]['type'], 'Solo')
def test_read_animation_manifest_not_animation(self):
from library.zips import read_animation_manifest
path = self._write_zip(_zip_bytes({'data/x.txt': 'x'}))
try:
manifest = read_animation_manifest(path)
finally:
os.unlink(path)
self.assertIsNone(manifest)
def test_version_upload_captures_manifest_and_tags(self):
self.project = Project.objects.create(
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
)
resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip())
self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']))
version = self.project.versions.first()
self.assertEqual(version.animation_id, 'jakebreath:testpack')
self.assertEqual(version.animation_manifest['animations'][0]['name'], 'Ground')
# Auto-created content tags (including the problem tag).
names = set(TagList.objects.filter(project=self.project).values_list('tag__name', flat=True))
self.assertIn('missionary', names)
self.assertIn('bugged', names)
def test_version_upload_warns_without_animation_id(self):
self.project = Project.objects.create(
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
)
resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False))
self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']),
fetch_redirect_response=False)
version = self.project.versions.first()
self.assertEqual(version.animation_id, '')
self.assertTrue(version.animation_manifest)
# The warning banner renders on the redirect target (before it's consumed).
resp = self.client.get(reverse('library:project_detail', args=['anim-pack']))
self.assertContains(resp, 'incompatible with the update-check API')
def test_latest_api_returns_version_and_url(self):
self.project = Project.objects.create(
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
)
self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip())
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['version'], '1.0.0')
self.assertEqual(data['pack_format'], 64)
self.assertIn('/api/files/', data['download_url'])
self.assertIn('?download=1', data['download_url'])
def test_latest_api_404_unknown_pack(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_packs_latest', args=['nope', 'missing']))
self.assertEqual(resp.status_code, 404)
def test_latest_api_404_without_animation_id(self):
self.project = Project.objects.create(
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
)
self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False))
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
self.assertEqual(resp.status_code, 404)
+1
View File
@@ -22,5 +22,6 @@ urlpatterns = [
path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'), path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'),
path('api/draft/', views.api_draft, name='api_draft'), path('api/draft/', views.api_draft, name='api_draft'),
path('api/tags/autocomplete/', views.api_tags_autocomplete, name='api_tags_autocomplete'), path('api/tags/autocomplete/', views.api_tags_autocomplete, name='api_tags_autocomplete'),
path('api/packs/<str:namespace>/<str:pack_id>/latest', views.api_packs_latest, name='api_packs_latest'),
path('api/users/autocomplete/', views.api_users_autocomplete, name='api_users_autocomplete'), path('api/users/autocomplete/', views.api_users_autocomplete, name='api_users_autocomplete'),
] ]
+105 -8
View File
@@ -30,7 +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 from .zips import parse_mods_manifest, read_animation_manifest, read_pack_mcmeta
def _open_indexed_file(file_index): def _open_indexed_file(file_index):
@@ -316,10 +316,24 @@ def project_detail(request, slug):
can_edit = project.can_edit(request.user) can_edit = project.can_edit(request.user)
guide_docs = [] guide_docs = []
if project.category == 'guide': animation_manifest = None
anim_stats = None
latest = versions.first() latest = versions.first()
if 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:
animation_manifest = latest.animation_manifest
anims = animation_manifest.get('animations') or []
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')
anim_stats = {
'total': len(anims),
'entity': entity_count,
'player': len(anims) - entity_count - solo_count,
'solo': solo_count,
'flagged': sum(1 for a in anims if a.get('problem_tags')),
'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}),
}
context = { context = {
'project': project, 'project': project,
@@ -328,10 +342,43 @@ def project_detail(request, slug):
'tags': tags, 'tags': tags,
'can_edit': can_edit, 'can_edit': can_edit,
'guide_docs': guide_docs, 'guide_docs': guide_docs,
'animation_manifest': animation_manifest,
'anim_stats': anim_stats,
} }
return render(request, 'library/project_detail.html', context) return render(request, 'library/project_detail.html', context)
@login_required
def api_packs_latest(request, namespace, pack_id):
"""Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id)."""
animation_id = f'{namespace}:{pack_id}'
version = (
Version.objects.filter(animation_id=animation_id)
.select_related('project').prefetch_related('files__file')
.order_by('-created_at').first()
)
if version is None:
return JsonResponse({'detail': 'Not found'}, status=404)
release = next(
(vf for vf in version.files.all() if vf.kind == 'release'),
version.files.first(),
)
if release is None:
return JsonResponse({'detail': 'No file'}, status=404)
download_url = request.build_absolute_uri(
reverse('library:file_request', args=[release.file.uuid]) + '?download=1'
)
return JsonResponse({
'namespace': namespace,
'pack_id': pack_id,
'name': version.project.title,
'version': version.version_name,
'pack_format': version.pack_format,
'created_at': version.created_at.isoformat(),
'download_url': download_url,
})
def guide_doc(request, slug, version_id, file_uuid): def guide_doc(request, slug, version_id, file_uuid):
"""Render one markdown guide document server-side (used by the switcher).""" """Render one markdown guide document server-side (used by the switcher)."""
version = get_object_or_404( version = get_object_or_404(
@@ -410,9 +457,34 @@ def _stored_path(file_index):
return Path(settings.MEDIA_ROOT) / file_index.stored_path return Path(settings.MEDIA_ROOT) / file_index.stored_path
def _finalize_version(project, version_name, changelog, temp_uploads): ANIMATION_API_WARNING = (
'Warning: your pack is incompatible with the update-check API endpoint and '
'may not work with applications that check for updates. Add an '
"'animationframework' block to pack.mcmeta, e.g.: "
'{"pack":{"pack_format":64},"animationframework":{"id":"yourname:yourpack",'
'"name":"Your Pack","author":"You","version":"1.0.0","description":"..."}}'
)
def _apply_content_tags(project, tag_names, actor):
"""Auto-create a content:<tag> for every distinct animation content tag."""
content_cat = TagCategory.objects.filter(slug='content').first()
if content_cat is None:
return
for name in tag_names:
name = slugify_tag(name)
if not name:
continue
tag, _ = Tag.objects.get_or_create(
name=name, category=content_cat, defaults={'created_by': actor},
)
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': 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).""" capturing category-specific metadata (pack.mcmeta / mods manifest / animation
manifest). 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,
@@ -421,6 +493,8 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
pack_format = None pack_format = None
pack_description = '' pack_description = ''
mods_manifest = {} mods_manifest = {}
animation_manifest = None
missing_animation_id = False
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')
@@ -442,6 +516,12 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
manifest = parse_mods_manifest(_stored_path(index), index.original_filename) manifest = parse_mods_manifest(_stored_path(index), 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:
@@ -451,9 +531,20 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
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_manifest is not None:
version.animation_manifest = animation_manifest
update_fields.append('animation_manifest')
if animation_manifest.get('animation_id'):
version.animation_id = animation_manifest['animation_id']
update_fields.append('animation_id')
if update_fields: if update_fields:
version.save(update_fields=update_fields) version.save(update_fields=update_fields)
return version
if animation_manifest is not None:
_apply_content_tags(
project, animation_manifest.get('content_tags') or [], actor,
)
return version, missing_animation_id
def _form_values(*forms): def _form_values(*forms):
@@ -494,12 +585,15 @@ def project_create(request):
thumb.save(update_fields=['status']) thumb.save(update_fields=['status'])
project.save(update_fields=['thumbnail']) project.save(update_fields=['thumbnail'])
_finalize_version( _, missing_animation_id = _finalize_version(
project, project,
version_form.cleaned_data['version_name'], version_form.cleaned_data['version_name'],
version_form.cleaned_data['changelog'], version_form.cleaned_data['changelog'],
version_files, version_files,
request.user,
) )
if missing_animation_id:
messages.warning(request, ANIMATION_API_WARNING)
for temp in TempUpload.objects.filter( for temp in TempUpload.objects.filter(
user=request.user, status='pending', kind='media', user=request.user, status='pending', kind='media',
@@ -631,12 +725,15 @@ def version_upload(request, slug):
if not version_files: if not version_files:
form.add_error('version_name', 'Upload at least one version file before uploading.') form.add_error('version_name', 'Upload at least one version file before uploading.')
else: else:
_finalize_version( _, missing_animation_id = _finalize_version(
project, project,
form.cleaned_data['version_name'], form.cleaned_data['version_name'],
form.cleaned_data['changelog'], form.cleaned_data['changelog'],
version_files, version_files,
request.user,
) )
if missing_animation_id:
messages.warning(request, ANIMATION_API_WARNING)
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.') messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
return redirect('library:project_detail', slug=project.slug) return redirect('library:project_detail', slug=project.slug)
+167 -1
View File
@@ -8,6 +8,14 @@ adopted file (under MEDIA_ROOT) and return plain dicts/values.
import json import json
import zipfile import zipfile
INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
PROBLEM_TAGS = {'broken', 'bugged', 'borked'}
def _decode(data):
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
return json.loads(data.decode('utf-8-sig', 'replace'))
def _open_zip(path): def _open_zip(path):
"""Return a ZipFile for the path (None when it's not a zip).""" """Return a ZipFile for the path (None when it's not a zip)."""
@@ -27,7 +35,7 @@ def read_pack_mcmeta(path):
return None, '' return None, ''
try: try:
try: try:
data = json.loads(zf.read('pack.mcmeta').decode('utf-8', 'replace')) data = _decode(zf.read('pack.mcmeta'))
except (KeyError, json.JSONDecodeError): except (KeyError, json.JSONDecodeError):
return None, '' return None, ''
pack = data.get('pack') or {} pack = data.get('pack') or {}
@@ -43,6 +51,164 @@ def read_pack_mcmeta(path):
zf.close() zf.close()
def _friendly_entity(entity):
"""minecraft:zombie -> Zombie; needsofnature:horse_liquid_collector -> Horse Liquid Collector."""
name = entity.rsplit(':', 1)[-1]
return name.replace('_', ' ').title()
def _gender(actor_tags):
for tag in actor_tags or []:
if str(tag).startswith('gender.'):
return str(tag).split('.', 1)[1]
return None
def _summarize_animdef(filename, data):
stem = filename.rsplit('/', 1)[-1]
if stem.endswith('.json'):
stem = stem[:-5]
display = data.get('display_name') or stem.replace('_', ' ').title()
participants = len(data.get('actors') or [])
actors = []
has_entity = False
entity_names = []
for actor in data.get('actors') or []:
entity_types = actor.get('entity_types') or []
entity = 'player'
if entity_types:
non_players = [e for e in entity_types if e != 'minecraft:player']
if non_players:
entity = _friendly_entity(non_players[0])
has_entity = True
entity_names.append(entity)
injector = actor.get('injector')
inj = None
inj_name = None
if injector is True:
inj = 'injector'
inj_name = 'injector'
elif isinstance(injector, str) and injector:
inj = injector
inj_name = INJECTOR_NAMES.get(injector, injector)
actors.append({
'label': actor.get('label') or '',
'entity': entity,
'gender': _gender(actor.get('actor_tags')),
'activity': actor.get('activity') or '',
'injector': inj,
'injector_name': inj_name,
'receiver': bool(actor.get('receiver')),
})
content_tags = [t for t in data.get('content_tags') or []]
animation_tags = data.get('animation_tags') or []
problem_tags = sorted({
t for t in list(content_tags) + list(animation_tags)
if str(t).lower() in PROBLEM_TAGS
})
if participants == 1:
type_ = 'Solo'
elif has_entity:
type_ = 'Entity x Player'
elif participants == 2:
type_ = 'Pair'
elif participants == 3:
type_ = 'Threesome'
else:
type_ = f'Group of {participants}'
stages = []
for stage in data.get('stages') or []:
stages.append({
'stage': stage.get('stage'),
'loop': bool(stage.get('loop')),
'cycle_seconds': stage.get('cycle_seconds'),
'speed': stage.get('speed'),
'climax': bool(stage.get('non_peak') or stage.get('manual_peak')),
'use_stage': stage.get('use_stage'),
'joinable': bool(stage.get('allow_join', True)),
'escapable': bool(stage.get('escapable', True)),
})
return {
'name': display,
'content_tags': content_tags,
'animation_tags': list(animation_tags),
'type': type_,
'entity_names': entity_names,
'participants': participants,
'actors': actors,
'stages': stages,
'weight': data.get('weight'),
'block_requirements': bool(data.get('block_requirements')),
'water': bool(data.get('water')),
'problem_tags': problem_tags,
}
def read_animation_manifest(path):
"""Build the animation manifest for a NoN animation pack.
Detected by the presence of data/*/afw_animdefs/*.json. Returns None when
the archive isn't an animation pack. Reads the animationframework block
(id/name/version/authors/description) plus a summary of every animdef.
"""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
animdef_names = sorted(
n for n in names if '/afw_animdefs/' in n and n.endswith('.json')
)
if not animdef_names:
return None
animation_id = None
pack_name = ''
pack_version = ''
description = ''
authors = []
try:
mcmeta = _decode(zf.read('pack.mcmeta'))
af = mcmeta.get('animationframework') or {}
animation_id = af.get('id') or None
pack_name = str(af.get('name') or '')
pack_version = str(af.get('version') or '')
description = str(af.get('description') or '')
author = af.get('author')
if author:
authors = [a.strip() for a in str(author).split(',') if a.strip()]
except (KeyError, json.JSONDecodeError):
pass
animations = []
distinct_content_tags = set()
for name in animdef_names:
try:
data = _decode(zf.read(name))
except (KeyError, json.JSONDecodeError):
continue
summary = _summarize_animdef(name, data)
distinct_content_tags.update(summary['content_tags'])
animations.append(summary)
return {
'animation_id': animation_id,
'name': pack_name,
'version': pack_version,
'authors': authors,
'description': description,
'animations': animations,
'content_tags': sorted(distinct_content_tags),
}
finally:
zf.close()
def parse_mods_manifest(path, filename=''): def parse_mods_manifest(path, filename=''):
"""Inspect a modpack archive and build a manifest for the Mods tab. """Inspect a modpack archive and build a manifest for the Mods tab.
+169
View File
@@ -2859,3 +2859,172 @@ a.deletelink {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* ========== Global messages banner ========== */
.messages-banner {
margin-bottom: 16px;
}
.message {
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 8px;
font-size: 0.9rem;
border: 1px solid transparent;
}
.message-success {
background: var(--md-sys-color-primary-container, #d8f3dc);
color: var(--md-sys-color-on-primary-container, #1b4332);
border-color: var(--md-sys-color-primary);
}
.message-warning {
background: var(--md-sys-color-error-container, #fdecea);
color: var(--md-sys-color-on-error-container, #7f1d1d);
border-color: var(--md-sys-color-error, #b3261e);
}
.message-error {
background: var(--md-sys-color-error-container, #fdecea);
color: var(--md-sys-color-on-error-container, #7f1d1d);
border-color: var(--md-sys-color-error, #b3261e);
}
/* ========== Animations tab ========== */
.animations-header h2 {
margin: 0 0 8px;
}
.anim-pack-name {
font-size: 1.05rem;
font-weight: 600;
margin: 0 0 4px;
}
.anim-authors {
margin: 0 0 4px;
color: var(--md-sys-color-on-surface-variant);
font-size: 0.9rem;
}
.anim-description {
margin: 0 0 8px;
font-size: 0.85rem;
color: var(--md-sys-color-on-surface-variant);
}
.anim-warning {
margin: 10px 0 0;
padding: 8px 12px;
border-radius: 8px;
background: var(--md-sys-color-error-container, #fdecea);
color: var(--md-sys-color-on-error-container, #7f1d1d);
font-size: 0.82rem;
}
.animations-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin: 14px 0;
padding: 10px 12px;
border-radius: 10px;
background: var(--md-sys-color-surface-variant);
font-size: 0.82rem;
color: var(--md-sys-color-on-surface-variant);
}
.animations-summary strong {
color: var(--md-sys-color-on-surface);
}
.anim-summary-flagged strong {
color: var(--md-sys-color-error);
}
.anim-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.anim-card {
padding: 12px;
border-radius: 12px;
border: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface-container-low);
display: flex;
flex-direction: column;
gap: 8px;
}
.anim-card-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.anim-name {
font-size: 0.95rem;
}
.anim-type-badge {
padding: 1px 8px;
border-radius: 999px;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
font-size: 0.68rem;
font-weight: 600;
}
.anim-problem {
color: var(--md-sys-color-error);
cursor: help;
}
.anim-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.anim-actors {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.actor-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
background: var(--md-sys-color-surface-variant);
font-size: 0.75rem;
color: var(--md-sys-color-on-surface);
}
.actor-chip small {
opacity: 0.7;
}
.injector-badge {
padding: 1px 6px;
border-radius: 999px;
background: var(--md-sys-color-secondary-container, #e8def8);
color: var(--md-sys-color-on-secondary-container, #21005d);
font-size: 0.68rem;
font-weight: 600;
}
.anim-stages {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.stages-count {
font-size: 0.75rem;
color: var(--md-sys-color-on-surface-variant);
margin-right: 4px;
}
.stage-chip {
padding: 1px 6px;
border-radius: 6px;
background: var(--md-sys-color-surface-variant);
font-size: 0.7rem;
color: var(--md-sys-color-on-surface-variant);
}
.stage-chip .fa-star {
color: var(--md-sys-color-error);
margin-right: 2px;
}
.anim-extras {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.anim-extra {
font-size: 0.72rem;
color: var(--md-sys-color-on-surface-variant);
}
+7
View File
@@ -47,6 +47,13 @@
</nav> </nav>
</div> </div>
<main class="main-content"> <main class="main-content">
{% if messages %}
<div class="messages-banner">
{% for message in messages %}
<div class="message message-{{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
@@ -64,6 +64,7 @@
{% if project.category == 'guide' %}Guide{% else %}Description{% endif %} {% if project.category == 'guide' %}Guide{% else %}Description{% endif %}
</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 project.category == 'modpack' %}<button class="tab-btn" data-tab="mods" role="tab">Mods</button>{% endif %} {% 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>
@@ -138,6 +139,96 @@
</div> </div>
</section> </section>
{% if animation_manifest %}
<section class="tab-panel" id="tab-animations">
<div class="card">
<div class="animations-header">
<h2><i class="fas fa-film"></i> Animations</h2>
{% if animation_manifest.name %}
<p class="anim-pack-name">{{ animation_manifest.name }}
{% if animation_manifest.version %}<span class="pack-format-badge">v{{ animation_manifest.version }}</span>{% endif %}
</p>
{% endif %}
<p class="anim-authors">
<i class="fas fa-user-pen"></i> by
{% for author in animation_manifest.authors %}{{ author }}{% if not forloop.last %}, {% endif %}{% empty %}{{ project.owner.username }}{% endfor %}
</p>
{% if animation_manifest.description %}<p class="anim-description">{{ animation_manifest.description }}</p>{% endif %}
{% if not animation_manifest.animation_id %}
<p class="anim-warning"><i class="fas fa-exclamation-triangle"></i> This pack has no <code>animationframework.id</code> — it can't be reached through the update-check API.</p>
{% endif %}
</div>
{% if anim_stats %}
<div class="animations-summary">
<span><strong>{{ anim_stats.total }}</strong> animations</span>
<span><strong>{{ anim_stats.player }}</strong> Player × Player</span>
<span><strong>{{ anim_stats.entity }}</strong> Entity × Player</span>
<span><strong>{{ anim_stats.solo }}</strong> solo</span>
{% if anim_stats.flagged %}<span class="anim-summary-flagged"><strong>{{ anim_stats.flagged }}</strong> flagged ⚠</span>{% endif %}
<span><strong>{{ anim_stats.tags|length }}</strong> tags</span>
</div>
{% endif %}
<div class="anim-grid">
{% for anim in animation_manifest.animations %}
<div class="anim-card">
<div class="anim-card-head">
<strong class="anim-name">{{ anim.name }}</strong>
<span class="anim-type-badge">{{ anim.type }}</span>
{% if anim.problem_tags %}
<span class="anim-problem" title="{{ anim.problem_tags|join:', ' }}"><i class="fas fa-exclamation-triangle"></i></span>
{% endif %}
</div>
<div class="anim-tags">
{% for tag in anim.content_tags %}
<span class="tag-chip" style="border-color: var(--md-sys-color-content, #d62828); color: var(--md-sys-color-content, #d62828);">{{ tag }}</span>
{% endfor %}
{% for tag in anim.animation_tags %}
<span class="tag-chip">{{ tag }}</span>
{% endfor %}
</div>
<div class="anim-actors">
{% for actor in anim.actors %}
<span class="actor-chip">
{% if actor.entity == 'player' %}
<i class="fas fa-user"></i> Player
{% else %}
<i class="fas fa-paw"></i> {{ actor.entity }}
{% endif %}
{% if actor.gender %}{% if actor.gender == 'male' %} ♂{% elif actor.gender == 'female' %} ♀{% endif %}{% endif %}
{% if actor.activity %}<small>· {{ actor.activity }}</small>{% endif %}
{% if actor.injector %}
<span class="injector-badge"{% if actor.injector != 'injector' %} title="Injector: {{ actor.injector_name }}"{% endif %}>
{% if actor.injector == 'injector' %}injector{% else %}{{ actor.injector_name }} ({{ actor.injector }}){% endif %}
</span>
{% endif %}
{% if actor.receiver %}<small>· receiver</small>{% endif %}
</span>
{% endfor %}
</div>
<div class="anim-stages">
<span class="stages-count">{{ anim.stages|length }} stage{{ anim.stages|length|pluralize }}</span>
{% for s in anim.stages %}
<span class="stage-chip"{% if s.climax %} title="climax stage"{% endif %}>
{% if s.climax %}<i class="fas fa-star"></i>{% endif %}{{ s.stage }}{% if s.loop %}↻{% endif %}{% if s.cycle_seconds %} {{ s.cycle_seconds }}s{% endif %}
</span>
{% endfor %}
</div>
{% if anim.weight or anim.block_requirements or anim.water %}
<div class="anim-extras">
{% if anim.weight %}<span class="anim-extra">weight {{ anim.weight }}</span>{% endif %}
{% if anim.block_requirements %}<span class="anim-extra"><i class="fas fa-cubes"></i> requires block support</span>{% endif %}
{% if anim.water %}<span class="anim-extra"><i class="fas fa-water"></i> water</span>{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% if project.category == 'modpack' %} {% if project.category == 'modpack' %}
<section class="tab-panel" id="tab-mods"> <section class="tab-panel" id="tab-mods">
<div class="card"> <div class="card">