Initial implementation of inventree

A small IT asset inventory system built around a nested folder tree,
matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS,
SQLite storage (WAL, FKs, migrations), embedded templates/static assets,
single compiled binary.

- AJAX-driven inventory tree: lazy-loaded folder expansion with per-user
  persisted expand state, drag-and-drop move for folders/assets, safe
  delete-with-relocate-or-cascade confirmation, search with breadcrumbs.
- Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs,
  CSV import (with optional folder auto-create) and export.
- Admin-defined item types with custom fields (text/long_text/number/date/
  boolean/dropdown/url/ip_address).
- Configurable statuses, session-based auth (bcrypt, secure cookies),
  admin/user roles, user management.
- CLI: serve, version, database migrate, database backup; /healthz;
  graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
Owen
2026-08-17 13:34:40 -05:00
commit 218687a1ee
71 changed files with 8102 additions and 0 deletions
+722
View File
@@ -0,0 +1,722 @@
// inventree - vanilla JS driving the inventory tree and AJAX panels.
// No build step, no frameworks: plain DOM APIs + fetch.
(function () {
'use strict';
var treePane = document.getElementById('tree-pane');
var mainPanel = document.getElementById('main-panel');
var isAdmin = document.body.classList.contains('role-admin');
var draggedNode = null;
var formReturnContext = { type: 'root', id: '' };
// ---------------------------------------------------------------------
// Generic helpers
// ---------------------------------------------------------------------
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
async function fetchText(url) {
var res = await fetch(url);
if (!res.ok) throw new Error('Request failed (' + res.status + ')');
return res.text();
}
async function apiFetch(url, opts) {
opts = opts || {};
var fetchOpts = { method: opts.method || 'GET', headers: {} };
if (opts.json !== undefined) {
fetchOpts.headers['Content-Type'] = 'application/json';
fetchOpts.body = JSON.stringify(opts.json);
}
var res = await fetch(url, fetchOpts);
var body = null;
var ct = res.headers.get('content-type') || '';
if (ct.indexOf('application/json') !== -1) {
body = await res.json();
}
if (!res.ok) {
var msg = (body && body.error) ? body.error : ('Request failed (' + res.status + ')');
throw new Error(msg);
}
return body ? body.data : null;
}
// ---------------------------------------------------------------------
// Main panel navigation
// ---------------------------------------------------------------------
async function loadPanel(type, id) {
var url;
if (type === 'root') url = '/app/panel/root';
else if (type === 'folder') url = '/app/panel/folders/' + id;
else if (type === 'asset') url = '/app/panel/assets/' + id;
else return;
var html = await fetchText(url);
mainPanel.innerHTML = html;
mainPanel.dataset.contextType = type;
mainPanel.dataset.contextId = id || '';
}
async function reloadCurrentPanel() {
var type = mainPanel.dataset.contextType || 'root';
var id = mainPanel.dataset.contextId || '';
if (type !== 'root' && type !== 'folder' && type !== 'asset') return;
await loadPanel(type, id);
}
function selectTreeRowVisually(type, id) {
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
var row;
if (type === 'root') {
row = document.querySelector('.tree-row[data-type="root"]');
} else {
var node = document.querySelector('.tree-node[data-type="' + type + '"][data-id="' + id + '"]');
row = node ? node.querySelector(':scope > .tree-row') : null;
}
if (row) row.classList.add('selected');
}
async function navigateAndSelect(type, id) {
await loadPanel(type, id);
selectTreeRowVisually(type, id);
}
async function selectNode(row) {
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
row.classList.add('selected');
var type = row.dataset.type;
var id = row.dataset.id || '';
await loadPanel(type, id);
}
// ---------------------------------------------------------------------
// Tree: refresh / expand / collapse
// ---------------------------------------------------------------------
async function refreshWholeTree() {
var ul = document.getElementById('top-level-children');
if (!ul) return;
var html = await fetchText('/app/tree/children');
ul.innerHTML = html;
}
async function toggleFolder(toggleBtn) {
var li = toggleBtn.closest('.tree-node');
var childUl = li.querySelector(':scope > ul.tree-children');
var id = toggleBtn.dataset.id;
var expanded = toggleBtn.classList.contains('expanded');
if (expanded) {
toggleBtn.classList.remove('expanded');
if (childUl) childUl.classList.add('d-none');
fetch('/app/tree/collapse', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'folder_id=' + encodeURIComponent(id),
});
} else {
toggleBtn.classList.add('expanded');
if (childUl) {
childUl.classList.remove('d-none');
var html = await fetchText('/app/tree/children?parent_id=' + encodeURIComponent(id) + '&persist=1');
childUl.innerHTML = html;
}
}
}
async function expandAllUnder(ul) {
var folderLis = Array.prototype.filter.call(ul.children, function (li) {
return li.dataset && li.dataset.type === 'folder';
});
for (var i = 0; i < folderLis.length; i++) {
var li = folderLis[i];
var toggle = li.querySelector(':scope > .tree-row > .tree-toggle');
if (toggle && !toggle.classList.contains('expanded')) {
await toggleFolder(toggle);
}
var childUl = li.querySelector(':scope > ul.tree-children');
if (childUl) await expandAllUnder(childUl);
}
}
async function expandAllHandler() {
var top = document.getElementById('top-level-children');
if (top) await expandAllUnder(top);
}
async function collapseAllHandler() {
document.querySelectorAll('.tree-toggle.expanded').forEach(function (t) {
t.classList.remove('expanded');
var li = t.closest('.tree-node');
var childUl = li ? li.querySelector(':scope > ul.tree-children') : null;
if (childUl) childUl.classList.add('d-none');
});
try { await fetch('/app/tree/collapse-all', { method: 'POST' }); } catch (e) { /* ignore */ }
}
// ---------------------------------------------------------------------
// Tree context menu ("...")
// ---------------------------------------------------------------------
var ctxMenu = null;
function ensureCtxMenu() {
if (ctxMenu) return ctxMenu;
ctxMenu = document.createElement('div');
ctxMenu.className = 'dropdown-menu tree-ctx-menu';
ctxMenu.style.position = 'fixed';
ctxMenu.style.display = 'none';
ctxMenu.innerHTML =
'<button type="button" class="dropdown-item" data-menu-action="new-subfolder"><i class="bi bi-folder-plus"></i> New Subfolder</button>' +
'<button type="button" class="dropdown-item" data-menu-action="new-asset"><i class="bi bi-plus-lg"></i> Add Asset</button>' +
'<button type="button" class="dropdown-item" data-menu-action="rename"><i class="bi bi-pencil"></i> Rename</button>' +
'<button type="button" class="dropdown-item text-danger" data-menu-action="delete"><i class="bi bi-trash"></i> Delete</button>';
document.body.appendChild(ctxMenu);
return ctxMenu;
}
function closeCtxMenu() {
if (ctxMenu) ctxMenu.style.display = 'none';
}
function openTreeContextMenu(btn) {
var menu = ensureCtxMenu();
var rect = btn.getBoundingClientRect();
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
menu.style.top = rect.bottom + 'px';
menu.style.display = 'block';
menu.dataset.folderId = btn.dataset.id;
}
document.addEventListener('click', function (e) {
if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target) && !e.target.closest('.btn-tree-action')) {
closeCtxMenu();
}
var menuAction = e.target.closest('[data-menu-action]');
if (menuAction && ctxMenu) {
var id = ctxMenu.dataset.folderId;
var action = menuAction.dataset.menuAction;
closeCtxMenu();
if (action === 'new-subfolder') openFolderModal({ parentId: id });
else if (action === 'new-asset') loadAssetForm({ folderId: id });
else if (action === 'rename') openRenameFolder(id);
else if (action === 'delete') startDeleteFolder(id);
}
});
// ---------------------------------------------------------------------
// Folder create / rename modal
// ---------------------------------------------------------------------
var folderModalEl = document.getElementById('folderModal');
var folderModal = (folderModalEl && window.bootstrap) ? new bootstrap.Modal(folderModalEl) : null;
function openFolderModal(opts) {
if (!folderModal) return;
document.getElementById('folderFormId').value = '';
document.getElementById('folderFormParentId').value = (opts && opts.parentId) || '';
document.getElementById('folderFormName').value = '';
document.getElementById('folderFormDescription').value = '';
document.getElementById('folderFormIcon').value = '';
document.getElementById('folderFormSortOrder').value = '0';
document.getElementById('folderModalTitle').textContent = 'New Folder';
document.getElementById('folderFormError').classList.add('d-none');
folderModal.show();
}
async function openRenameFolder(id) {
if (!folderModal) return;
try {
var f = await apiFetch('/app/folders/' + id);
document.getElementById('folderFormId').value = f.ID;
document.getElementById('folderFormParentId').value = '';
document.getElementById('folderFormName').value = f.Name;
document.getElementById('folderFormDescription').value = f.Description;
document.getElementById('folderFormIcon').value = f.Icon;
document.getElementById('folderFormSortOrder').value = f.SortOrder;
document.getElementById('folderModalTitle').textContent = 'Rename Folder';
document.getElementById('folderFormError').classList.add('d-none');
folderModal.show();
} catch (err) {
alert('Could not load folder: ' + err.message);
}
}
var folderFormEl = document.getElementById('folderForm');
if (folderFormEl) {
folderFormEl.addEventListener('submit', async function (e) {
e.preventDefault();
var id = document.getElementById('folderFormId').value;
var payload = {
name: document.getElementById('folderFormName').value,
description: document.getElementById('folderFormDescription').value,
icon: document.getElementById('folderFormIcon').value,
sort_order: Number(document.getElementById('folderFormSortOrder').value || 0),
};
var errorBox = document.getElementById('folderFormError');
errorBox.classList.add('d-none');
try {
if (id) {
await apiFetch('/app/folders/' + id, { method: 'PATCH', json: payload });
} else {
var parentId = document.getElementById('folderFormParentId').value;
payload.parent_id = parentId ? Number(parentId) : null;
await apiFetch('/app/folders', { method: 'POST', json: payload });
}
folderModal.hide();
await refreshWholeTree();
await reloadCurrentPanel();
} catch (err) {
errorBox.textContent = err.message;
errorBox.classList.remove('d-none');
}
});
}
// ---------------------------------------------------------------------
// Folder delete modal
// ---------------------------------------------------------------------
var deleteFolderModalEl = document.getElementById('deleteFolderModal');
var deleteFolderModal = (deleteFolderModalEl && window.bootstrap) ? new bootstrap.Modal(deleteFolderModalEl) : null;
async function startDeleteFolder(id) {
if (!deleteFolderModal) return;
try {
var info = await apiFetch('/app/folders/' + id + '/delete-info');
deleteFolderModalEl.dataset.folderId = id;
document.getElementById('deleteFolderName').textContent = info.folder_name;
var empty = info.folder_count === 0 && info.asset_count === 0;
document.getElementById('deleteFolderEmptyNotice').classList.toggle('d-none', !empty);
document.getElementById('deleteFolderNotEmptyNotice').classList.toggle('d-none', empty);
if (!empty) {
document.getElementById('deleteFolderCounts').textContent =
info.folder_count + ' folder(s) and ' + info.asset_count + ' asset(s)';
var sel = document.getElementById('deleteFolderDestination');
sel.innerHTML = info.destinations.map(function (d) {
return '<option value="' + d.id + '">' + escapeHtml(d.path) + '</option>';
}).join('');
document.getElementById('modeRelocate').checked = true;
}
deleteFolderModal.show();
} catch (err) {
alert('Could not load folder info: ' + err.message);
}
}
var confirmDeleteFolderBtn = document.getElementById('confirmDeleteFolderBtn');
if (confirmDeleteFolderBtn) {
confirmDeleteFolderBtn.addEventListener('click', async function () {
var id = deleteFolderModalEl.dataset.folderId;
var notEmpty = !document.getElementById('deleteFolderNotEmptyNotice').classList.contains('d-none');
var url = '/app/folders/' + id;
if (notEmpty) {
var mode = document.querySelector('input[name="delete_mode"]:checked').value;
if (mode === 'cascade') {
url += '?mode=cascade';
} else {
var dest = document.getElementById('deleteFolderDestination').value;
url += '?mode=relocate&dest=' + encodeURIComponent(dest);
}
}
try {
await apiFetch(url, { method: 'DELETE' });
deleteFolderModal.hide();
await refreshWholeTree();
await navigateAndSelect('root', '');
} catch (err) {
alert('Delete failed: ' + err.message);
}
});
}
// ---------------------------------------------------------------------
// Asset form (create / edit)
// ---------------------------------------------------------------------
async function loadAssetForm(opts) {
formReturnContext = { type: mainPanel.dataset.contextType || 'root', id: mainPanel.dataset.contextId || '' };
var params = new URLSearchParams();
if (opts && opts.folderId) params.set('folder_id', opts.folderId);
var qs = params.toString();
var html = await fetchText('/app/panel/assets/new' + (qs ? '?' + qs : ''));
mainPanel.innerHTML = html;
mainPanel.dataset.contextType = 'asset-form';
mainPanel.dataset.contextId = '';
}
async function loadAssetEditForm(id) {
var html = await fetchText('/app/panel/assets/' + id + '/edit');
mainPanel.innerHTML = html;
mainPanel.dataset.contextType = 'asset-form';
mainPanel.dataset.contextId = id;
}
function cancelAssetForm(btn) {
if (btn.dataset.id) {
navigateAndSelect('asset', btn.dataset.id);
} else {
navigateAndSelect(formReturnContext.type, formReturnContext.id);
}
}
async function submitAssetForm(form) {
var errorBox = document.getElementById('assetFormError');
errorBox.classList.add('d-none');
var fd = new FormData(form);
var payload = { fields: {} };
for (var pair of fd.entries()) {
var key = pair[0], value = pair[1];
if (key.indexOf('field_') === 0) {
payload.fields[key.slice(6)] = value;
} else if (key === 'item_type_id') {
payload.item_type_id = Number(value);
} else if (key === 'folder_id') {
payload.folder_id = value ? Number(value) : null;
} else if (key === 'status_id') {
payload.status_id = Number(value);
} else if (key === 'assigned_user_id') {
payload.assigned_user_id = value ? Number(value) : null;
} else {
payload[key] = value;
}
}
var mode = form.dataset.mode;
var url = mode === 'edit' ? '/app/assets/' + form.dataset.assetId : '/app/assets';
var method = mode === 'edit' ? 'PATCH' : 'POST';
try {
var res = await apiFetch(url, { method: method, json: payload });
await refreshWholeTree();
await navigateAndSelect('asset', res.id);
} catch (err) {
errorBox.textContent = err.message;
errorBox.classList.remove('d-none');
}
}
async function duplicateAsset(id) {
try {
var res = await apiFetch('/app/assets/' + id + '/duplicate', { method: 'POST' });
await refreshWholeTree();
await navigateAndSelect('asset', res.id);
} catch (err) {
alert('Duplicate failed: ' + err.message);
}
}
async function deleteAsset(id, name) {
if (!confirm('Delete asset "' + name + '"? This cannot be undone.')) return;
try {
var res = await apiFetch('/app/assets/' + id, { method: 'DELETE' });
await refreshWholeTree();
if (res.folder_id) await navigateAndSelect('folder', res.folder_id);
else await navigateAndSelect('root', '');
} catch (err) {
alert('Delete failed: ' + err.message);
}
}
async function confirmMoveAsset(id, selectId) {
var select = document.getElementById(selectId);
var val = select.value;
try {
await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } });
var modalEl = select.closest('.modal');
if (modalEl && window.bootstrap) {
var inst = bootstrap.Modal.getInstance(modalEl);
if (inst) inst.hide();
}
await refreshWholeTree();
await navigateAndSelect('asset', id);
} catch (err) {
alert('Move failed: ' + err.message);
}
}
// ---------------------------------------------------------------------
// Folder listing filters / pagination
// ---------------------------------------------------------------------
async function loadFolderPanelWithFilters(folderId, overrides) {
var form = mainPanel.querySelector('.panel-filter-form');
var params = new URLSearchParams();
if (form) {
var fd = new FormData(form);
for (var pair of fd.entries()) {
if (pair[1]) params.set(pair[0], pair[1]);
}
}
if (overrides) {
for (var k in overrides) params.set(k, overrides[k]);
}
var qs = params.toString();
var html = await fetchText('/app/panel/folders/' + folderId + (qs ? '?' + qs : ''));
mainPanel.innerHTML = html;
mainPanel.dataset.contextType = 'folder';
mainPanel.dataset.contextId = folderId;
}
// ---------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------
async function runSearch(q) {
if (!q || !q.trim()) return;
var html = await fetchText('/app/search?q=' + encodeURIComponent(q));
mainPanel.innerHTML = html;
mainPanel.dataset.contextType = 'search';
mainPanel.dataset.contextId = '';
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
}
var treeSearchEl = document.getElementById('tree-search');
if (treeSearchEl) {
var searchDebounce;
treeSearchEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
runSearch(treeSearchEl.value);
}
});
treeSearchEl.addEventListener('input', function () {
clearTimeout(searchDebounce);
var q = treeSearchEl.value;
if (!q) return;
searchDebounce = setTimeout(function () { runSearch(q); }, 400);
});
}
// ---------------------------------------------------------------------
// CSV import
// ---------------------------------------------------------------------
var importFormEl = document.getElementById('importForm');
if (importFormEl) {
importFormEl.addEventListener('submit', async function (e) {
e.preventDefault();
var resultBox = document.getElementById('importResult');
resultBox.innerHTML = '<div class="text-body-secondary">Importing&hellip;</div>';
var fd = new FormData(importFormEl);
try {
var res = await fetch('/import/assets.csv', { method: 'POST', body: fd });
var body = await res.json();
if (!res.ok || !body.ok) throw new Error(body.error || 'Import failed');
var data = body.data;
var html = '<div class="alert alert-success py-2">Created ' + data.Created + ', updated ' + data.Updated + '.</div>';
if (data.Errors && data.Errors.length) {
html += '<div class="alert alert-warning py-2"><strong>' + data.Errors.length + ' note(s):</strong><ul class="mb-0">' +
data.Errors.map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('') + '</ul></div>';
}
resultBox.innerHTML = html;
await refreshWholeTree();
await reloadCurrentPanel();
} catch (err) {
resultBox.innerHTML = '<div class="alert alert-danger py-2">' + escapeHtml(err.message) + '</div>';
}
});
}
// ---------------------------------------------------------------------
// Tree pane: click delegation
// ---------------------------------------------------------------------
if (treePane) {
treePane.addEventListener('click', function (e) {
if (e.target.closest('#btn-expand-all')) { expandAllHandler(); return; }
if (e.target.closest('#btn-collapse-all')) { collapseAllHandler(); return; }
if (e.target.closest('#btn-new-root-folder')) { openFolderModal({ parentId: null }); return; }
var toggle = e.target.closest('.tree-toggle');
if (toggle) { e.stopPropagation(); toggleFolder(toggle); return; }
var actionBtn = e.target.closest('.btn-tree-action');
if (actionBtn) { e.stopPropagation(); openTreeContextMenu(actionBtn); return; }
var row = e.target.closest('.tree-row');
if (row && row.dataset.type && !row.classList.contains('tree-row-more')) {
selectNode(row);
}
});
// Drag and drop (admin only)
treePane.addEventListener('dragstart', function (e) {
if (!isAdmin) { e.preventDefault(); return; }
var li = e.target.closest('.tree-node[draggable="true"]');
if (!li) return;
draggedNode = { type: li.dataset.type, id: li.dataset.id };
li.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', draggedNode.type + ':' + draggedNode.id);
});
treePane.addEventListener('dragend', function () {
document.querySelectorAll('.tree-node.dragging').forEach(function (n) { n.classList.remove('dragging'); });
document.querySelectorAll('.tree-row.drop-target').forEach(function (n) { n.classList.remove('drop-target'); });
draggedNode = null;
});
treePane.addEventListener('dragover', function (e) {
if (!draggedNode) return;
var row = e.target.closest('.tree-row');
if (!row) return;
var type = row.dataset.type;
if (type !== 'folder' && type !== 'root') return;
if (type === 'folder' && draggedNode.type === 'folder' && draggedNode.id === row.dataset.id) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
row.classList.add('drop-target');
});
treePane.addEventListener('dragleave', function (e) {
var row = e.target.closest('.tree-row');
if (row) row.classList.remove('drop-target');
});
treePane.addEventListener('drop', async function (e) {
if (!draggedNode) return;
var row = e.target.closest('.tree-row');
if (!row) return;
var type = row.dataset.type;
if (type !== 'folder' && type !== 'root') return;
e.preventDefault();
row.classList.remove('drop-target');
var destId = type === 'root' ? null : Number(row.dataset.id);
var dragged = draggedNode;
draggedNode = null;
try {
if (dragged.type === 'folder') {
await apiFetch('/app/folders/' + dragged.id + '/move', { method: 'POST', json: { parent_id: destId } });
} else if (dragged.type === 'asset') {
await apiFetch('/app/assets/' + dragged.id + '/move', { method: 'POST', json: { folder_id: destId } });
}
await refreshWholeTree();
await reloadCurrentPanel();
} catch (err) {
alert('Move failed: ' + err.message);
}
});
}
// ---------------------------------------------------------------------
// Main panel: click / change / submit delegation
// ---------------------------------------------------------------------
if (mainPanel) {
mainPanel.addEventListener('click', function (e) {
var folderLink = e.target.closest('.folder-link');
if (folderLink) { e.preventDefault(); navigateAndSelect('folder', folderLink.dataset.id); return; }
var assetLink = e.target.closest('.asset-link, .asset-row');
if (assetLink) { e.preventDefault(); navigateAndSelect('asset', assetLink.dataset.id); return; }
var crumb = e.target.closest('.breadcrumb-link');
if (crumb) { e.preventDefault(); navigateAndSelect(crumb.dataset.type, crumb.dataset.id || ''); return; }
var pageLink = e.target.closest('.panel-page-link');
if (pageLink) { e.preventDefault(); loadFolderPanelWithFilters(pageLink.dataset.folderId, { page: pageLink.dataset.page }); return; }
var newSub = e.target.closest('[data-action="new-subfolder"]');
if (newSub) { openFolderModal({ parentId: newSub.dataset.id }); return; }
var renameBtn = e.target.closest('[data-action="rename-folder"]');
if (renameBtn) { openRenameFolder(renameBtn.dataset.id); return; }
var deleteBtn = e.target.closest('[data-action="delete-folder"]');
if (deleteBtn) { startDeleteFolder(deleteBtn.dataset.id); return; }
var newAssetBtn = e.target.closest('[data-action="new-asset"]');
if (newAssetBtn) { loadAssetForm({ folderId: newAssetBtn.dataset.folderId }); return; }
var editAssetBtn = e.target.closest('[data-action="edit-asset"]');
if (editAssetBtn) { loadAssetEditForm(editAssetBtn.dataset.id); return; }
var dupBtn = e.target.closest('[data-action="duplicate-asset"]');
if (dupBtn) { duplicateAsset(dupBtn.dataset.id); return; }
var delAssetBtn = e.target.closest('[data-action="delete-asset"]');
if (delAssetBtn) { deleteAsset(delAssetBtn.dataset.id, delAssetBtn.dataset.name); return; }
var confirmMoveBtn = e.target.closest('[data-action="confirm-move-asset"]');
if (confirmMoveBtn) { confirmMoveAsset(confirmMoveBtn.dataset.id, confirmMoveBtn.dataset.select); return; }
var cancelFormBtn = e.target.closest('[data-action="cancel-asset-form"]');
if (cancelFormBtn) { cancelAssetForm(cancelFormBtn); return; }
});
mainPanel.addEventListener('change', async function (e) {
if (e.target.id === 'assetFormItemType') {
var html = await fetchText('/app/forms/asset-fields?item_type_id=' + e.target.value);
var container = document.getElementById('asset-fields-container');
if (container) container.innerHTML = html;
return;
}
var filterForm = e.target.closest('.panel-filter-form');
if (filterForm) {
loadFolderPanelWithFilters(filterForm.dataset.folderId, { page: 1 });
}
});
var filterDebounce;
mainPanel.addEventListener('input', function (e) {
if (e.target.matches && e.target.matches('.panel-filter-form input[name="q"]')) {
clearTimeout(filterDebounce);
var form = e.target.closest('.panel-filter-form');
filterDebounce = setTimeout(function () { loadFolderPanelWithFilters(form.dataset.folderId, { page: 1 }); }, 350);
}
});
mainPanel.addEventListener('submit', function (e) {
if (e.target.matches && e.target.matches('.panel-filter-form')) {
e.preventDefault();
loadFolderPanelWithFilters(e.target.dataset.folderId, { page: 1 });
return;
}
if (e.target.id === 'assetForm') {
e.preventDefault();
submitAssetForm(e.target);
}
});
// Assets/folders are also draggable as drop-into-root targets from the
// panel view when browsing (drag onto the tree's root row still works
// via the tree pane handler above); dragstart on panel rows is not
// implemented since the tree is the primary drag surface.
}
// ---------------------------------------------------------------------
// Item type custom field admin form (item-types/{id}/edit page)
// ---------------------------------------------------------------------
var fieldForm = document.getElementById('fieldForm');
if (fieldForm) {
document.addEventListener('click', function (e) {
var editBtn = e.target.closest('.field-edit-btn');
if (editBtn) {
document.getElementById('fieldFormTitle').textContent = 'Edit Field';
document.getElementById('fieldFormName').value = editBtn.dataset.name;
document.getElementById('fieldFormType').value = editBtn.dataset.type;
document.getElementById('fieldFormDefault').value = editBtn.dataset.default;
document.getElementById('fieldFormOptions').value = editBtn.dataset.options || '';
document.getElementById('fieldFormRequired').checked = editBtn.dataset.required === 'true';
document.getElementById('fieldFormSortOrder').value = editBtn.dataset.sortOrder;
document.getElementById('fieldFormSubmit').textContent = 'Save';
fieldForm.action = window.itemTypeFieldsBaseAction + '/' + editBtn.dataset.id;
var cancelBtn = document.getElementById('fieldFormCancel');
cancelBtn.classList.remove('d-none');
fieldForm.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
var cancelBtn2 = e.target.closest('#fieldFormCancel');
if (cancelBtn2) {
document.getElementById('fieldFormTitle').textContent = 'Add Field';
fieldForm.reset();
document.getElementById('fieldFormSubmit').textContent = 'Add';
fieldForm.action = window.itemTypeFieldsBaseAction;
cancelBtn2.classList.add('d-none');
}
});
}
})();
File diff suppressed because one or more lines are too long