Replaces every native confirm() with a shared P.confirm() modal (a hung native dialog was found live to block the whole tab, including auto-refresh, and once even double-confirmed an unrelated deletion). Also fixes Edit Container silently resetting to Bridge/blanking the Static IP for any container on a custom network, and a context menu losing its anchor to a mid-read auto-refresh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
406 lines
17 KiB
JavaScript
406 lines
17 KiB
JavaScript
/**
|
|
* javascript/templates.js
|
|
*
|
|
* The "Apps" panel: two sub-views toggled by a segmented control —
|
|
* "Store" (browses/searches Community Applications' own public app feed
|
|
* directly, see ajax/templates.php's ca_feed_search()) and "My Templates"
|
|
* (this plugin's own saved, reusable container configs, XML in the same
|
|
* schema Unraid's own Docker Manager templates use). "Use"/"Install" both
|
|
* hand off to containers.js's Create Container modal, pre-filled;
|
|
* templates are themselves created either from that same modal's "Save as
|
|
* template" checkbox, or by installing a Store app (which saves it as a
|
|
* template too, so it shows up under My Templates afterward).
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
const P = window.Podman;
|
|
let allTemplates = [];
|
|
let activeSubview = 'store';
|
|
let storeResults = [];
|
|
let storeSearchTimer = null;
|
|
|
|
function iconHtml(t) {
|
|
if (t.icon) {
|
|
return '<img class="podman-template-icon" src="' + P.escapeHtml(t.icon) + '" alt="" loading="lazy" ' +
|
|
'onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'podman-template-icon podman-template-icon-fallback\',textContent:\'' +
|
|
P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '\'}))">';
|
|
}
|
|
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
|
|
}
|
|
|
|
// --- My Templates --------------------------------------------------------
|
|
|
|
function cardHtml(t) {
|
|
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
|
|
return '' +
|
|
'<div class="podman-template-card" data-name="' + P.escapeHtml(t.name) + '">' +
|
|
iconHtml(t) +
|
|
'<div class="podman-template-body">' +
|
|
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
|
|
'<div class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</div>' +
|
|
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
|
|
'</div>' +
|
|
'<div class="podman-template-actions">' +
|
|
'<button class="podman-btn podman-btn-primary" data-action="use">Use</button>' +
|
|
'<button class="podman-btn podman-btn-ghost" data-action="export">Export</button>' +
|
|
'<button class="podman-btn podman-btn-ghost podman-btn-danger" data-action="delete">Delete</button>' +
|
|
'</div></div>';
|
|
}
|
|
|
|
function renderTemplatesGrid() {
|
|
const grid = P.el('templates-grid');
|
|
grid.innerHTML = allTemplates.length
|
|
? allTemplates.map(cardHtml).join('')
|
|
: '<div class="podman-empty-note">No templates yet — save one from the "New Container" form, install one from the Store, or import an XML template.</div>';
|
|
}
|
|
|
|
function load() {
|
|
return P.get('templates', 'list').then(function (data) {
|
|
allTemplates = data;
|
|
renderTemplatesGrid();
|
|
}).catch(function (err) {
|
|
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function handleTemplatesGridClick(e) {
|
|
const btn = e.target.closest('button[data-action]');
|
|
if (!btn) return;
|
|
const name = btn.closest('.podman-template-card').dataset.name;
|
|
|
|
if (btn.dataset.action === 'use') {
|
|
btn.disabled = true;
|
|
P.get('templates', 'get', { name: name }).then(function (config) {
|
|
btn.disabled = false;
|
|
P.openCreateContainerModal(config);
|
|
}).catch(function (err) {
|
|
btn.disabled = false;
|
|
P.toast('Could not load template: ' + err.message, 'error');
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (btn.dataset.action === 'export') {
|
|
btn.disabled = true;
|
|
P.get('templates', 'export', { name: name }).then(function (data) {
|
|
btn.disabled = false;
|
|
const blob = new Blob([data.xml], { type: 'application/xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name + '.xml';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}).catch(function (err) {
|
|
btn.disabled = false;
|
|
P.toast('Export failed: ' + err.message, 'error');
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (btn.dataset.action === 'delete') {
|
|
P.confirm('Delete template "' + name + '"? This does not affect any running containers.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
|
|
if (!ok) return;
|
|
btn.disabled = true;
|
|
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
|
|
btn.disabled = false;
|
|
P.toast('Delete failed: ' + err.message, 'error');
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Store -----------------------------------------------------------------
|
|
|
|
function storeCardHtml(a) {
|
|
const overview = a.overview && a.overview.length > 110 ? a.overview.slice(0, 107) + '…' : (a.overview || '');
|
|
return '' +
|
|
'<div class="podman-template-card" data-template-url="' + P.escapeHtml(a.templateUrl) + '">' +
|
|
iconHtml(a) +
|
|
'<div class="podman-template-body">' +
|
|
'<div class="podman-template-name">' + P.escapeHtml(a.name) + '</div>' +
|
|
'<div class="podman-row-sub mono">' + P.escapeHtml(a.image) + '</div>' +
|
|
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
|
|
'</div>' +
|
|
'<div class="podman-template-actions">' +
|
|
'<button class="podman-btn podman-btn-primary" data-action="install">Install</button>' +
|
|
'</div></div>';
|
|
}
|
|
|
|
let storeQuery = '';
|
|
let storeSort = 'newest';
|
|
let storePage = 1;
|
|
let storeTotalPages = 1;
|
|
|
|
function renderStoreGrid() {
|
|
const grid = P.el('store-grid');
|
|
grid.innerHTML = storeResults.length
|
|
? storeResults.map(storeCardHtml).join('')
|
|
: '<div class="podman-empty-note">No matches.</div>';
|
|
}
|
|
|
|
function renderStorePager() {
|
|
P.el('store-pager').style.display = storeTotalPages > 1 ? '' : 'none';
|
|
P.el('store-pager-label').textContent = 'Page ' + storePage + ' of ' + storeTotalPages;
|
|
P.el('store-pager-prev').disabled = storePage <= 1;
|
|
P.el('store-pager-next').disabled = storePage >= storeTotalPages;
|
|
}
|
|
|
|
// The sort toggle only means anything while browsing (no search term) —
|
|
// a search is always alphabetical (see ca_feed_search()'s own comment on
|
|
// why "newest"/downloads-based ordering doesn't make sense for a filtered
|
|
// result set), so the toggle is hidden rather than left present but inert.
|
|
function updateSortToggleVisibility() {
|
|
P.el('store-sort-toggle').style.display = storeQuery ? 'none' : '';
|
|
}
|
|
|
|
function loadStore() {
|
|
const grid = P.el('store-grid');
|
|
grid.innerHTML = '<div class="podman-empty-note">Loading…</div>';
|
|
updateSortToggleVisibility();
|
|
return P.get('templates', 'apps_search', { q: storeQuery, page: storePage, sort: storeSort }).then(function (data) {
|
|
storeResults = data.results;
|
|
storePage = data.page;
|
|
storeTotalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
|
|
renderStoreGrid();
|
|
renderStorePager();
|
|
}).catch(function (err) {
|
|
grid.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function handleStoreGridClick(e) {
|
|
const btn = e.target.closest('button[data-action="install"]');
|
|
if (!btn) return;
|
|
const templateUrl = btn.closest('.podman-template-card').dataset.templateUrl;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Installing…';
|
|
P.post('templates', 'import_url', { url: templateUrl }).then(function (result) {
|
|
return P.get('templates', 'get', { name: result.name });
|
|
}).then(function (config) {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Install';
|
|
P.openCreateContainerModal(config);
|
|
load(); // refreshes "My Templates" in the background — it's now saved there too
|
|
}).catch(function (err) {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Install';
|
|
P.toast('Install failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
// --- Import Template modal ---------------------------------------------
|
|
|
|
/**
|
|
* A modal (not its own tab — tried that, but a modal is enough room for
|
|
* this and keeps the nav from growing another entry for what's really a
|
|
* secondary action off "My Templates").
|
|
*/
|
|
function openImportModal() {
|
|
const backdrop = document.createElement('div');
|
|
backdrop.className = 'podman-modal-backdrop';
|
|
backdrop.innerHTML = '' +
|
|
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
|
'<div class="podman-modal-head"><h3>Import Template</h3></div>' +
|
|
'<form class="podman-modal-body">' +
|
|
'<div class="podman-modal-field"><label>Your existing Docker templates</label>' +
|
|
'<input type="text" id="ti-local-search" placeholder="Search by name…">' +
|
|
'<div class="podman-local-template-list" id="ti-local-list"><div class="podman-empty-note">Loading…</div></div>' +
|
|
'</div>' +
|
|
'<div class="podman-modal-field"><label>Or import from a URL</label>' +
|
|
'<div style="display:flex; gap:6px;">' +
|
|
'<input type="url" id="ti-url" placeholder="https://raw.githubusercontent.com/.../template.xml" style="flex:1;">' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-role="fetch-url">Fetch & Import</button>' +
|
|
'</div></div>' +
|
|
'<div class="podman-modal-field"><label>Or paste XML directly</label>' +
|
|
'<textarea id="ti-xml" rows="8" class="mono" placeholder="This plugin\'s own export format, or an Unraid/Community Applications Docker template." style="width:100%; resize:vertical;"></textarea></div>' +
|
|
'</form>' +
|
|
'<div class="podman-modal-actions">' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
|
|
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Import pasted XML</button>' +
|
|
'</div></div>';
|
|
|
|
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
|
|
|
let localTemplates = [];
|
|
function renderLocalList(filter) {
|
|
const list = backdrop.querySelector('#ti-local-list');
|
|
const visible = filter
|
|
? localTemplates.filter(function (t) { return t.name.toLowerCase().indexOf(filter) !== -1; })
|
|
: localTemplates;
|
|
if (!visible.length) {
|
|
list.innerHTML = '<div class="podman-empty-note">' + (localTemplates.length ? 'No match.' : 'None found.') + '</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = visible.map(function (t) {
|
|
return '<div class="podman-local-template-item" data-file="' + P.escapeHtml(t.file) + '">' +
|
|
'<span class="podman-local-template-name">' + P.escapeHtml(t.name) + '</span>' +
|
|
'<span class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</span>' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-action="import-local">Import</button>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
P.get('templates', 'list_local').then(function (data) {
|
|
localTemplates = data;
|
|
renderLocalList('');
|
|
}).catch(function () {
|
|
backdrop.querySelector('#ti-local-list').innerHTML = '<div class="podman-empty-note">Could not read local templates.</div>';
|
|
});
|
|
backdrop.querySelector('#ti-local-search').addEventListener('input', function (e) {
|
|
renderLocalList(e.target.value.trim().toLowerCase());
|
|
});
|
|
backdrop.querySelector('#ti-local-list').addEventListener('click', function (e) {
|
|
const btn = e.target.closest('[data-action="import-local"]');
|
|
if (!btn) return;
|
|
const file = btn.closest('.podman-local-template-item').dataset.file;
|
|
btn.disabled = true;
|
|
P.post('templates', 'import_local', { file: file }).then(function () {
|
|
close();
|
|
return load();
|
|
}).catch(function (err) {
|
|
btn.disabled = false;
|
|
showError(err.message);
|
|
});
|
|
});
|
|
|
|
backdrop.querySelector('#ti-xml').focus();
|
|
|
|
function close() { backdrop.remove(); }
|
|
|
|
function showError(message) {
|
|
let box = backdrop.querySelector('.podman-modal-error');
|
|
if (!box) {
|
|
box = document.createElement('div');
|
|
box.className = 'podman-modal-error';
|
|
backdrop.querySelector('.podman-modal-body').appendChild(box);
|
|
}
|
|
box.textContent = message;
|
|
}
|
|
|
|
function submit() {
|
|
const xml = backdrop.querySelector('#ti-xml').value.trim();
|
|
if (!xml) {
|
|
showError('Paste a template XML first.');
|
|
return;
|
|
}
|
|
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
|
submitBtn.disabled = true;
|
|
P.post('templates', 'import', { xml: xml }).then(function () {
|
|
close();
|
|
return load();
|
|
}).catch(function (err) {
|
|
submitBtn.disabled = false;
|
|
showError(err.message);
|
|
});
|
|
}
|
|
|
|
function submitUrl() {
|
|
const url = backdrop.querySelector('#ti-url').value.trim();
|
|
if (!url) {
|
|
showError('Enter a URL first.');
|
|
return;
|
|
}
|
|
const fetchBtn = backdrop.querySelector('[data-role="fetch-url"]');
|
|
fetchBtn.disabled = true;
|
|
P.post('templates', 'import_url', { url: url }).then(function () {
|
|
close();
|
|
return load();
|
|
}).catch(function (err) {
|
|
fetchBtn.disabled = false;
|
|
showError(err.message);
|
|
});
|
|
}
|
|
|
|
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
|
|
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
|
|
backdrop.querySelector('[data-role="fetch-url"]').addEventListener('click', submitUrl);
|
|
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
|
|
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
|
|
document.addEventListener('keydown', function onKey(e) {
|
|
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
|
|
});
|
|
}
|
|
|
|
// --- Shell / sub-view toggle --------------------------------------------
|
|
|
|
function switchSubview(view) {
|
|
activeSubview = view;
|
|
document.querySelectorAll('#apps-subview-toggle button').forEach(function (b) {
|
|
b.classList.toggle('active', b.dataset.view === view);
|
|
});
|
|
P.el('apps-store-view').style.display = view === 'store' ? '' : 'none';
|
|
P.el('apps-templates-view').style.display = view === 'templates' ? '' : 'none';
|
|
P.el('apps-store-search').style.display = view === 'store' ? '' : 'none';
|
|
P.el('templates-import-btn').style.display = view === 'templates' ? '' : 'none';
|
|
if (view === 'store') {
|
|
updateSortToggleVisibility();
|
|
} else {
|
|
P.el('store-sort-toggle').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
const container = P.el('podman-panel-templates');
|
|
container.innerHTML = '' +
|
|
'<div class="podman-card">' +
|
|
'<div class="podman-toolbar">' +
|
|
'<div class="podman-segmented" id="apps-subview-toggle">' +
|
|
'<button class="active" data-view="store">Store</button>' +
|
|
'<button data-view="templates">My Templates</button>' +
|
|
'</div>' +
|
|
'<input class="podman-search" id="apps-store-search" type="text" placeholder="Search Community Applications…">' +
|
|
'<div class="podman-segmented" id="store-sort-toggle">' +
|
|
'<button class="active" data-sort="newest">Newest</button>' +
|
|
'<button data-sort="alpha">A-Z</button>' +
|
|
'</div>' +
|
|
'<button class="podman-btn podman-btn-ghost" id="templates-import-btn" style="display:none;">⬆ Import Template</button>' +
|
|
'</div>' +
|
|
'<div id="apps-store-view">' +
|
|
'<div class="podman-template-grid" id="store-grid"></div>' +
|
|
'<div class="podman-pager" id="store-pager" style="display:none;">' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-prev">‹ Prev</button>' +
|
|
'<span id="store-pager-label"></span>' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-next">Next ›</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div id="apps-templates-view" style="display:none;"><div class="podman-template-grid" id="templates-grid"></div></div>' +
|
|
'</div>';
|
|
|
|
document.querySelectorAll('#apps-subview-toggle button').forEach(function (b) {
|
|
b.addEventListener('click', function () { switchSubview(b.dataset.view); });
|
|
});
|
|
P.el('apps-store-search').addEventListener('input', function (e) {
|
|
storeQuery = e.target.value.trim();
|
|
storePage = 1;
|
|
if (storeSearchTimer) clearTimeout(storeSearchTimer);
|
|
storeSearchTimer = setTimeout(loadStore, 400);
|
|
});
|
|
document.querySelectorAll('#store-sort-toggle button').forEach(function (b) {
|
|
b.addEventListener('click', function () {
|
|
storeSort = b.dataset.sort;
|
|
storePage = 1;
|
|
document.querySelectorAll('#store-sort-toggle button').forEach(function (x) { x.classList.toggle('active', x === b); });
|
|
loadStore();
|
|
});
|
|
});
|
|
P.el('store-pager-prev').addEventListener('click', function () {
|
|
if (storePage <= 1) return;
|
|
storePage -= 1;
|
|
loadStore();
|
|
});
|
|
P.el('store-pager-next').addEventListener('click', function () {
|
|
if (storePage >= storeTotalPages) return;
|
|
storePage += 1;
|
|
loadStore();
|
|
});
|
|
P.el('store-grid').addEventListener('click', handleStoreGridClick);
|
|
P.el('templates-grid').addEventListener('click', handleTemplatesGridClick);
|
|
P.el('templates-import-btn').addEventListener('click', openImportModal);
|
|
|
|
loadStore();
|
|
return load();
|
|
}
|
|
|
|
P.registerPanel('templates', { init: init, refresh: load });
|
|
})();
|