full suport for models and hotloading Plugins
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-08-05 02:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0008_version_logical_manifest_version_models_manifest'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='version',
|
||||||
|
name='plugins_manifest',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -209,6 +209,7 @@ 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)
|
||||||
|
plugins_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)
|
models_manifest = models.JSONField(default=dict, blank=True)
|
||||||
|
|||||||
@@ -1030,8 +1030,8 @@ class UGCZipParsingTests(TestCase):
|
|||||||
from library.zips import parse_mods_manifest
|
from library.zips import parse_mods_manifest
|
||||||
path = self._write_zip(_zip_bytes({
|
path = self._write_zip(_zip_bytes({
|
||||||
'modrinth.index.json': json.dumps({
|
'modrinth.index.json': json.dumps({
|
||||||
'dependencies': [
|
'files': [
|
||||||
{'project_id': 'abc', 'file_name': 'mod-a.jar', 'dependency_type': 'required'},
|
{'path': 'mods/mod-a.jar', 'env': {'client': 'required', 'server': 'required'}},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
}), suffix='.mrpack')
|
}), suffix='.mrpack')
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from .zips import (
|
|||||||
read_models_manifest,
|
read_models_manifest,
|
||||||
read_pack_meta,
|
read_pack_meta,
|
||||||
read_pack_mcmeta,
|
read_pack_mcmeta,
|
||||||
|
read_plugins_manifest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -331,6 +332,7 @@ def project_detail(request, slug):
|
|||||||
models_manifest = None
|
models_manifest = None
|
||||||
logical_manifest = None
|
logical_manifest = None
|
||||||
mods_manifest = None
|
mods_manifest = None
|
||||||
|
plugins_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:
|
||||||
@@ -340,6 +342,7 @@ def project_detail(request, slug):
|
|||||||
models_manifest = latest.models_manifest or None
|
models_manifest = latest.models_manifest or None
|
||||||
logical_manifest = latest.logical_manifest or None
|
logical_manifest = latest.logical_manifest or None
|
||||||
mods_manifest = latest.mods_manifest or None
|
mods_manifest = latest.mods_manifest or None
|
||||||
|
plugins_manifest = latest.plugins_manifest or None
|
||||||
if animation_manifest:
|
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')
|
||||||
@@ -353,6 +356,14 @@ def project_detail(request, slug):
|
|||||||
'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}),
|
'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
autoload_plugins_json = ''
|
||||||
|
if plugins_manifest:
|
||||||
|
members = [
|
||||||
|
p['member'] for p in plugins_manifest.get('plugins', [])
|
||||||
|
if p.get('autoload') and p.get('member')
|
||||||
|
]
|
||||||
|
autoload_plugins_json = json.dumps(members)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'project': project,
|
'project': project,
|
||||||
'versions': versions,
|
'versions': versions,
|
||||||
@@ -364,6 +375,8 @@ def project_detail(request, slug):
|
|||||||
'models_manifest': models_manifest,
|
'models_manifest': models_manifest,
|
||||||
'logical_manifest': logical_manifest,
|
'logical_manifest': logical_manifest,
|
||||||
'mods_manifest': mods_manifest,
|
'mods_manifest': mods_manifest,
|
||||||
|
'plugins_manifest': plugins_manifest,
|
||||||
|
'autoload_plugins_json': autoload_plugins_json,
|
||||||
'anim_stats': anim_stats,
|
'anim_stats': anim_stats,
|
||||||
}
|
}
|
||||||
return render(request, 'library/project_detail.html', context)
|
return render(request, 'library/project_detail.html', context)
|
||||||
@@ -638,6 +651,7 @@ def _reparse_version(version):
|
|||||||
version.animation_manifest = read_animation_manifest(path) or {}
|
version.animation_manifest = read_animation_manifest(path) or {}
|
||||||
version.models_manifest = read_models_manifest(path, version.project.category) or {}
|
version.models_manifest = read_models_manifest(path, version.project.category) or {}
|
||||||
version.logical_manifest = read_logical_manifest(path) or {}
|
version.logical_manifest = read_logical_manifest(path) or {}
|
||||||
|
version.plugins_manifest = read_plugins_manifest(path) or {} if version.project.category == 'model' else {}
|
||||||
manifest = parse_mods_manifest(path, release.file.original_filename)
|
manifest = parse_mods_manifest(path, release.file.original_filename)
|
||||||
version.mods_manifest = manifest if manifest.get('files') else {}
|
version.mods_manifest = manifest if manifest.get('files') else {}
|
||||||
fmt, desc = read_pack_mcmeta(path)
|
fmt, desc = read_pack_mcmeta(path)
|
||||||
@@ -659,6 +673,7 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
|
|||||||
pack_format = None
|
pack_format = None
|
||||||
pack_description = ''
|
pack_description = ''
|
||||||
mods_manifest = {}
|
mods_manifest = {}
|
||||||
|
plugins_manifest = None
|
||||||
animation_id = ''
|
animation_id = ''
|
||||||
animation_manifest = None
|
animation_manifest = None
|
||||||
models_manifest = None
|
models_manifest = None
|
||||||
@@ -701,6 +716,8 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
|
|||||||
manifest = parse_mods_manifest(path, index.original_filename)
|
manifest = parse_mods_manifest(path, index.original_filename)
|
||||||
if manifest.get('files'):
|
if manifest.get('files'):
|
||||||
mods_manifest = manifest
|
mods_manifest = manifest
|
||||||
|
if plugins_manifest is None and project.category == 'model':
|
||||||
|
plugins_manifest = read_plugins_manifest(path)
|
||||||
|
|
||||||
update_fields = []
|
update_fields = []
|
||||||
if pack_format is not None:
|
if pack_format is not None:
|
||||||
@@ -710,6 +727,9 @@ 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 plugins_manifest is not None:
|
||||||
|
version.plugins_manifest = plugins_manifest
|
||||||
|
update_fields.append('plugins_manifest')
|
||||||
if animation_id:
|
if animation_id:
|
||||||
version.animation_id = animation_id
|
version.animation_id = animation_id
|
||||||
update_fields.append('animation_id')
|
update_fields.append('animation_id')
|
||||||
|
|||||||
@@ -16,6 +16,71 @@ PROBLEM_TAGS = {'broken', 'bugged', 'borked'}
|
|||||||
MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb')
|
MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb')
|
||||||
|
|
||||||
|
|
||||||
|
def _in_plugins_dir(name):
|
||||||
|
"""True when a member lives inside a plugins/ folder (any nesting)."""
|
||||||
|
parts = name.split('/')
|
||||||
|
return 'plugins' in parts[:-1]
|
||||||
|
|
||||||
|
|
||||||
|
def read_plugins_manifest(path):
|
||||||
|
"""Scan the archive's plugins/ folder for Blockbench plugins.
|
||||||
|
|
||||||
|
A ``.js`` file under a ``plugins/`` directory that registers a Blockbench
|
||||||
|
plugin (contains ``Plugin.register(`` or ``BBPlugin.register(``) is listed.
|
||||||
|
``plugins/autoload.txt`` holds one plugin name per line; matching plugins
|
||||||
|
are marked ``autoload`` so the viewer can hotload them when a model opens.
|
||||||
|
Returns None when the pack has no plugins.
|
||||||
|
"""
|
||||||
|
zf = _open_zip(path)
|
||||||
|
if zf is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
autoload = set()
|
||||||
|
for n in names:
|
||||||
|
if _in_plugins_dir(n) and n.endswith('autoload.txt'):
|
||||||
|
try:
|
||||||
|
raw = zf.read(n).decode('utf-8', 'replace')
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
for line in raw.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
autoload.add(line)
|
||||||
|
|
||||||
|
plugins = []
|
||||||
|
for n in sorted(names):
|
||||||
|
if not _in_plugins_dir(n) or not n.endswith('.js'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = zf.read(n)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
if len(content) > 8 * 1024 * 1024:
|
||||||
|
continue
|
||||||
|
text = content.decode('utf-8', 'replace')
|
||||||
|
if 'Plugin.register(' not in text and 'BBPlugin.register(' not in text:
|
||||||
|
continue
|
||||||
|
base = n.rsplit('/', 1)[-1]
|
||||||
|
stem = base[:-3] if base.lower().endswith('.js') else base
|
||||||
|
plugins.append({
|
||||||
|
'name': base,
|
||||||
|
'member': n,
|
||||||
|
'size': len(content),
|
||||||
|
'autoload': base in autoload or stem in autoload,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not plugins:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'plugins': plugins,
|
||||||
|
'has_plugins': True,
|
||||||
|
'autoload_count': sum(1 for p in plugins if p['autoload']),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
zf.close()
|
||||||
|
|
||||||
|
|
||||||
def _decode(data):
|
def _decode(data):
|
||||||
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
|
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
|
||||||
return json.loads(data.decode('utf-8-sig', 'replace'))
|
return json.loads(data.decode('utf-8-sig', 'replace'))
|
||||||
@@ -668,7 +733,9 @@ def parse_mods_manifest(path, filename=''):
|
|||||||
parts = name.split('/')
|
parts = name.split('/')
|
||||||
if 'mods' not in parts or parts.index('mods') >= len(parts) - 1:
|
if 'mods' not in parts or parts.index('mods') >= len(parts) - 1:
|
||||||
continue
|
continue
|
||||||
if 'minecraft' in parts or '.minecraft' in parts:
|
# Full instance = .minecraft/mods or a nested <instance>/minecraft/mods;
|
||||||
|
# a root-level minecraft/mods folder is the classic folder layout.
|
||||||
|
if '.minecraft' in parts or (('minecraft' in parts) and parts.index('minecraft') > 0):
|
||||||
is_instance = True
|
is_instance = True
|
||||||
fname = parts[-1]
|
fname = parts[-1]
|
||||||
if fname and fname not in seen:
|
if fname and fname not in seen:
|
||||||
|
|||||||
@@ -3382,10 +3382,11 @@ a.deletelink {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--md-sys-color-surface-container-low, #f7f7f9);
|
background: var(--md-sys-color-surface-variant, #45475a);
|
||||||
|
color: var(--md-sys-color-on-surface, #cdd6f4);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.mods-item:hover { background: var(--md-sys-color-surface-container, #efeff3); }
|
.mods-item:hover { background: var(--md-sys-color-surface2, #585b70); }
|
||||||
.mods-name { font-weight: 600; }
|
.mods-name { font-weight: 600; }
|
||||||
.mods-file { color: var(--md-sys-color-on-surface-variant, #777); font-size: 0.78rem; }
|
.mods-file { color: var(--md-sys-color-on-surface-variant, #777); font-size: 0.78rem; }
|
||||||
.mods-badge {
|
.mods-badge {
|
||||||
@@ -3432,3 +3433,54 @@ a.deletelink {
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Plugins tab */
|
||||||
|
.plugins-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #fff4e0;
|
||||||
|
border: 1px solid #f0c878;
|
||||||
|
color: #7a4d00;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.plugins-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.plugins-toggle .switch { position: relative; display: inline-block; width: 42px; height: 24px; flex: 0 0 auto; }
|
||||||
|
.plugins-toggle .switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.plugins-toggle .slider {
|
||||||
|
position: absolute; cursor: pointer; inset: 0;
|
||||||
|
background: #cbd5e1; border-radius: 999px; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.plugins-toggle .slider:before {
|
||||||
|
content: ''; position: absolute; height: 18px; width: 18px; left: 3px; top: 3px;
|
||||||
|
background: #fff; border-radius: 50%; transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.plugins-toggle .switch input:checked + .slider { background: #059669; }
|
||||||
|
.plugins-toggle .switch input:checked + .slider:before { transform: translateX(18px); }
|
||||||
|
.plugins-list {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.plugins-list li {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem; border-radius: 6px;
|
||||||
|
background: var(--md-sys-color-surface-variant, #45475a);
|
||||||
|
color: var(--md-sys-color-on-surface, #cdd6f4);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.plugins-name { font-weight: 600; color: inherit; }
|
||||||
|
.plugins-badge.autoload {
|
||||||
|
margin-left: auto;
|
||||||
|
border-radius: 999px; background: #e0e7ff; color: #3730a3;
|
||||||
|
padding: 0.1rem 0.5rem; font-size: 0.68rem; font-weight: 600; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.plugins-size { color: var(--md-sys-color-on-surface-variant, #6b7280); font-size: 0.78rem; }
|
||||||
|
|||||||
@@ -177,6 +177,7 @@
|
|||||||
const member = btn.dataset.member;
|
const member = btn.dataset.member;
|
||||||
const name = btn.dataset.name || member;
|
const name = btn.dataset.name || member;
|
||||||
const modelFormat = btn.dataset.modelFormat || '.geo.json';
|
const modelFormat = btn.dataset.modelFormat || '.geo.json';
|
||||||
|
const slug = btn.dataset.projectSlug || '';
|
||||||
const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null };
|
const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null };
|
||||||
if (!member || !opts.baseUrl || busy) return;
|
if (!member || !opts.baseUrl || busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -223,6 +224,16 @@
|
|||||||
busy = false;
|
busy = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pack plugins to hotload (autoload.txt), unless the user opted out.
|
||||||
|
const noHotload = slug && localStorage.getItem('packs:no-hotload:' + slug) === '1';
|
||||||
|
if (!noHotload && btn.dataset.plugins) {
|
||||||
|
try {
|
||||||
|
const members = JSON.parse(btn.dataset.plugins);
|
||||||
|
msg.plugins = members.map(m => ({ member: m, url: assetUrl(opts.baseUrl, m) }));
|
||||||
|
} catch (e) {
|
||||||
|
msg.plugins = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
if (EMBEDDED_FORMATS.indexOf(modelFormat) !== -1) {
|
if (EMBEDDED_FORMATS.indexOf(modelFormat) !== -1) {
|
||||||
// Textures baked into the file — hand it to Blockbench as-is.
|
// Textures baked into the file — hand it to Blockbench as-is.
|
||||||
msg.modelFile = { format: modelFormat, content: await fetchModelText(member, opts.baseUrl) };
|
msg.modelFile = { format: modelFormat, content: await fetchModelText(member, opts.baseUrl) };
|
||||||
|
|||||||
@@ -103,9 +103,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
await hotloadPackPlugins(msg.plugins);
|
||||||
notify('model-open', { name: name });
|
notify('model-open', { name: name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hotload plugins bundled with the pack (served via pack_asset). A failing
|
||||||
|
// plugin is reported but never blocks the model preview.
|
||||||
|
async function hotloadPackPlugins(plugins) {
|
||||||
|
for (const p of (plugins || [])) {
|
||||||
|
if (!p || !p.url || !p.member) continue;
|
||||||
|
try {
|
||||||
|
const code = await (await fetch(p.url, { headers: { 'Accept': 'text/javascript' } })).text();
|
||||||
|
if (!code || code.length < 20) continue;
|
||||||
|
await new Plugin().loadFromFile({ path: p.member, content: code }, true);
|
||||||
|
notify('plugin-hotloaded', { name: p.member });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('packs pack plugin hotload failed', p.member, e);
|
||||||
|
notify('plugin-error', { error: 'Failed to load plugin ' + p.member });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (!e.data || e.data.type !== 'packs-open-model') return;
|
if (!e.data || e.data.type !== 'packs-open-model') return;
|
||||||
openModel(e.data).then(
|
openModel(e.data).then(
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
{% if models_manifest %}<button class="tab-btn" data-tab="models" role="tab">Models/Textures</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 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 %}
|
{% if mods_manifest %}<button class="tab-btn" data-tab="mods" role="tab">Mods</button>{% endif %}
|
||||||
|
{% if plugins_manifest %}<button class="tab-btn" data-tab="plugins" role="tab">Plugins</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>
|
||||||
|
|
||||||
@@ -250,6 +251,8 @@
|
|||||||
data-member="{{ model.member }}" data-name="{{ model.name }}"
|
data-member="{{ model.member }}" data-name="{{ model.name }}"
|
||||||
data-model-format="{{ model.ext }}"
|
data-model-format="{{ model.ext }}"
|
||||||
data-default-texture="{{ model.default_texture }}"
|
data-default-texture="{{ model.default_texture }}"
|
||||||
|
data-project-slug="{{ project.slug }}"
|
||||||
|
data-plugins="{{ autoload_plugins_json }}"
|
||||||
data-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}"
|
data-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}"
|
||||||
data-vanilla-base-url="/static/vanilla/ASSET"><i class="fas fa-cube"></i> Render</button>
|
data-vanilla-base-url="/static/vanilla/ASSET"><i class="fas fa-cube"></i> Render</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -390,10 +393,37 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if plugins_manifest %}
|
||||||
|
<section class="tab-panel" id="tab-plugins">
|
||||||
|
<div class="card">
|
||||||
|
<h2><i class="fas fa-puzzle-piece"></i> Plugins</h2>
|
||||||
|
<div class="plugins-warning">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> <strong>Caution:</strong> this pack contains Blockbench plugins — executable JavaScript that runs in your browser. Plugins can crash Blockbench or run arbitrary code. Only open models from packs you trust.
|
||||||
|
</div>
|
||||||
|
<div class="plugins-toggle">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="plugins-hotload-toggle" checked>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span>Hotload this pack's plugins when opening a model</span>
|
||||||
|
</div>
|
||||||
|
<ul class="plugins-list">
|
||||||
|
{% for plugin in plugins_manifest.plugins %}
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
<span class="plugins-name">{{ plugin.name }}</span>
|
||||||
|
{% if plugin.autoload %}<span class="plugins-badge autoload">autoload</span>{% endif %}
|
||||||
|
<span class="plugins-size">{{ plugin.size|filesizeformat }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="tab-panel" id="tab-gallery">
|
<section class="tab-panel" id="tab-gallery">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><i class="fas fa-images"></i> Gallery</h2>
|
<h2><i class="fas fa-images"></i> Gallery</h2> <div class="gallery-grid">
|
||||||
<div class="gallery-grid">
|
|
||||||
{% for asset in assets %}
|
{% for asset in assets %}
|
||||||
<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 }}">
|
<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 or asset.is_gif %}
|
{% if asset.is_video or asset.is_gif %}
|
||||||
@@ -537,6 +567,18 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-project "Don't hotload" switch for pack plugins.
|
||||||
|
const pluginsToggle = document.getElementById('plugins-hotload-toggle');
|
||||||
|
const pluginsSlug = document.querySelector('.model-render-btn')?.dataset.projectSlug;
|
||||||
|
if (pluginsToggle && pluginsSlug) {
|
||||||
|
const key = 'packs:no-hotload:' + pluginsSlug;
|
||||||
|
if (localStorage.getItem(key) === '1') pluginsToggle.checked = false;
|
||||||
|
pluginsToggle.addEventListener('change', () => {
|
||||||
|
if (pluginsToggle.checked) localStorage.removeItem(key);
|
||||||
|
else localStorage.setItem(key, '1');
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user