- Statuses can now be bulk-imported/updated via CSV (Name, Color columns; matches and updates existing statuses by name). New internal/statuses/csv.go, tested. - Icon fields (folder create/rename, item type form) are now a searchable dropdown of the 50 most common Bootstrap Icons instead of a free-text box, via a small reusable .icon-picker widget in app.js/app.css. - CSV asset import is now folder-scoped when triggered from the tree: right-click a folder (or its background) > Import CSV... imports every row directly into that folder, so the CSV needs no Folder column at all, and the option no longer appears at the top level. The original global, Folder-path-aware import (with auto-create-missing-folders) moved to a new admin-only Settings > Import / Export page, alongside the new Statuses import and format examples for both. assets.Import gained ImportOptions.TargetFolderID to drive the folder-scoped mode. - Site Name (Settings > General) now actually renders in the navbar brand and <title>, via a per-App "siteName" template func bound in loadTemplates. The brand is plain text now, not a link to /inventory.
975 lines
40 KiB
JavaScript
975 lines
40 KiB
JavaScript
// inventree - vanilla JS driving the inventory tree and AJAX panels.
|
|
// No build step, no frameworks: plain DOM APIs + fetch.
|
|
//
|
|
// Interaction model: the tree and panels behave like a native file manager.
|
|
// Left click selects/opens. Right click (or the keyboard Menu key, which
|
|
// browsers also fire as a "contextmenu" event) opens a context menu with
|
|
// the actions available for whatever was clicked - a folder, an asset, or
|
|
// empty background (which offers "New Folder" / "Add Asset" for wherever
|
|
// you right-clicked). There are intentionally very few standalone buttons.
|
|
(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;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Generic helpers
|
|
// ---------------------------------------------------------------------
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 */ }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Generic right-click context menu
|
|
// ---------------------------------------------------------------------
|
|
|
|
var ctxMenu = null;
|
|
|
|
function ensureCtxMenu() {
|
|
if (ctxMenu) return ctxMenu;
|
|
ctxMenu = document.createElement('div');
|
|
ctxMenu.className = 'dropdown-menu ctx-menu';
|
|
ctxMenu.style.display = 'none';
|
|
document.body.appendChild(ctxMenu);
|
|
return ctxMenu;
|
|
}
|
|
|
|
function closeCtxMenu() {
|
|
if (ctxMenu) ctxMenu.style.display = 'none';
|
|
}
|
|
|
|
// showContextMenu renders `items` (each {label, icon, danger, action} or
|
|
// {divider:true}) as a floating menu at the given viewport coordinates,
|
|
// clamped so it never runs off-screen.
|
|
function showContextMenu(x, y, items) {
|
|
items = items.filter(function (item, i) {
|
|
// Collapse a divider that would be first, last, or doubled.
|
|
if (!item.divider) return true;
|
|
var prevIsDivider = i === 0 || items[i - 1].divider;
|
|
var isLast = i === items.length - 1;
|
|
return !prevIsDivider && !isLast;
|
|
});
|
|
if (items.length === 0) return;
|
|
|
|
var menu = ensureCtxMenu();
|
|
menu.innerHTML = items.map(function (item) {
|
|
if (item.divider) return '<div class="dropdown-divider"></div>';
|
|
var cls = 'dropdown-item' + (item.danger ? ' text-danger' : '');
|
|
return '<button type="button" class="' + cls + '"><i class="bi ' + (item.icon || '') + '"></i> ' + escapeHtml(item.label) + '</button>';
|
|
}).join('');
|
|
|
|
var buttons = menu.querySelectorAll('button.dropdown-item');
|
|
var actionable = items.filter(function (i) { return !i.divider; });
|
|
buttons.forEach(function (btn, i) {
|
|
btn.addEventListener('click', function () {
|
|
closeCtxMenu();
|
|
actionable[i].action();
|
|
});
|
|
});
|
|
|
|
menu.style.display = 'block';
|
|
menu.style.left = '0px';
|
|
menu.style.top = '0px';
|
|
var rect = menu.getBoundingClientRect();
|
|
var left = Math.min(x, window.innerWidth - rect.width - 8);
|
|
var top = Math.min(y, window.innerHeight - rect.height - 8);
|
|
menu.style.left = Math.max(4, left) + 'px';
|
|
menu.style.top = Math.max(4, top) + 'px';
|
|
}
|
|
|
|
document.addEventListener('click', function (e) {
|
|
if (ctxMenu && ctxMenu.style.display === 'block' && !ctxMenu.contains(e.target)) closeCtxMenu();
|
|
});
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') closeCtxMenu();
|
|
});
|
|
window.addEventListener('scroll', closeCtxMenu, true);
|
|
window.addEventListener('resize', closeCtxMenu);
|
|
|
|
// ---- Menu builders per target kind -----------------------------------
|
|
|
|
function folderMenuItems(id) {
|
|
var items = [];
|
|
if (isAdmin) {
|
|
items.push({ label: 'New Subfolder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: id }); } });
|
|
items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: id }); } });
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Rename', icon: 'bi-pencil', action: function () { openRenameFolder(id); } });
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(id); } });
|
|
}
|
|
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv?folder_id=' + id + '&recursive=1'; } });
|
|
if (isAdmin) {
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { startDeleteFolder(id); } });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function assetMenuItems(id) {
|
|
var items = [{ label: 'Open', icon: 'bi-box-arrow-up-right', action: function () { navigateAndSelect('asset', id); } }];
|
|
if (isAdmin) {
|
|
items.push({ label: 'Edit', icon: 'bi-pencil', action: function () { loadAssetEditForm(id); } });
|
|
items.push({ label: 'Duplicate', icon: 'bi-files', action: function () { duplicateAsset(id); } });
|
|
items.push({ label: 'Move to…', icon: 'bi-arrows-move', action: function () { openMoveAssetModal(id); } });
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Delete', icon: 'bi-trash', danger: true, action: function () { deleteAsset(id); } });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// Import is intentionally folder-scoped only (see openImportModal) - it
|
|
// never appears at the top level, since a global admin import with
|
|
// folder-path resolution lives under Settings > Import / Export instead.
|
|
function treeBackgroundMenuItems() {
|
|
var items = [];
|
|
if (isAdmin) {
|
|
items.push({ label: 'New Folder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: null }); } });
|
|
items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: null }); } });
|
|
items.push({ divider: true });
|
|
}
|
|
items.push({ label: 'Expand All', icon: 'bi-arrows-expand', action: expandAllHandler });
|
|
items.push({ label: 'Collapse All', icon: 'bi-arrows-collapse', action: collapseAllHandler });
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = '/export/assets.csv'; } });
|
|
return items;
|
|
}
|
|
|
|
function panelBackgroundMenuItems(folderId) {
|
|
var items = [];
|
|
if (isAdmin) {
|
|
items.push({ label: folderId ? 'New Subfolder' : 'New Folder', icon: 'bi-folder-plus', action: function () { openFolderModal({ parentId: folderId }); } });
|
|
items.push({ label: 'Add Asset', icon: 'bi-plus-lg', action: function () { loadAssetForm({ folderId: folderId }); } });
|
|
if (folderId) {
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { openImportModal(folderId); } });
|
|
}
|
|
}
|
|
var exportUrl = folderId ? ('/export/assets.csv?folder_id=' + folderId + '&recursive=1') : '/export/assets.csv';
|
|
items.push({ divider: true });
|
|
items.push({ label: 'Export CSV', icon: 'bi-download', action: function () { window.location = exportUrl; } });
|
|
return items;
|
|
}
|
|
|
|
// ---- Wiring: tree pane -------------------------------------------------
|
|
|
|
if (treePane) {
|
|
treePane.addEventListener('contextmenu', function (e) {
|
|
if (e.target.closest('input, button')) return; // let native menus work on the search box etc.
|
|
var row = e.target.closest('.tree-row');
|
|
if (!row || row.dataset.type === 'root') {
|
|
e.preventDefault();
|
|
showContextMenu(e.clientX, e.clientY, treeBackgroundMenuItems());
|
|
return;
|
|
}
|
|
var type = row.dataset.type;
|
|
if (type !== 'folder' && type !== 'asset') return; // e.g. the "N more assets" row: no menu
|
|
e.preventDefault();
|
|
document.querySelectorAll('.tree-row.selected').forEach(function (r) { r.classList.remove('selected'); });
|
|
row.classList.add('selected');
|
|
showContextMenu(e.clientX, e.clientY, type === 'folder' ? folderMenuItems(row.dataset.id) : assetMenuItems(row.dataset.id));
|
|
});
|
|
}
|
|
|
|
// ---- Wiring: main panel -------------------------------------------------
|
|
|
|
if (mainPanel) {
|
|
mainPanel.addEventListener('contextmenu', function (e) {
|
|
var folderLink = e.target.closest('.folder-link');
|
|
if (folderLink) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderLink.dataset.id)); return; }
|
|
|
|
var assetRow = e.target.closest('.asset-link, .asset-row');
|
|
if (assetRow) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, assetMenuItems(assetRow.dataset.id)); return; }
|
|
|
|
var assetHeader = e.target.closest('[data-context="asset-header"]');
|
|
if (assetHeader) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, assetMenuItems(assetHeader.dataset.id)); return; }
|
|
|
|
var folderHeader = e.target.closest('[data-context="folder-header"]');
|
|
if (folderHeader) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderHeader.dataset.id)); return; }
|
|
|
|
var rootCrumb = e.target.closest('.breadcrumb [data-type="root"]');
|
|
if (rootCrumb) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, panelBackgroundMenuItems(null)); return; }
|
|
|
|
var folderCrumb = e.target.closest('.breadcrumb [data-type="folder"]');
|
|
if (folderCrumb) { e.preventDefault(); showContextMenu(e.clientX, e.clientY, folderMenuItems(folderCrumb.dataset.id)); return; }
|
|
|
|
if (e.target.closest('a, button, input, select, textarea, form, .list-group-item')) return;
|
|
var panelContent = e.target.closest('.panel-content');
|
|
var panelType = panelContent && panelContent.dataset.panelType;
|
|
if (panelType === 'root' || panelType === 'folder') {
|
|
e.preventDefault();
|
|
var folderId = panelType === 'folder' ? panelContent.dataset.folderId : null;
|
|
showContextMenu(e.clientX, e.clientY, panelBackgroundMenuItems(folderId));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Icon picker: a searchable dropdown of common Bootstrap Icons standing
|
|
// in for a free-text icon-class input (used by the folder modal and the
|
|
// item type form).
|
|
// ---------------------------------------------------------------------
|
|
|
|
var COMMON_ICONS = [
|
|
'bi-folder-fill', 'bi-folder', 'bi-archive', 'bi-box-seam', 'bi-boxes',
|
|
'bi-laptop', 'bi-pc-display', 'bi-pc', 'bi-display', 'bi-tv', 'bi-projector',
|
|
'bi-phone', 'bi-telephone', 'bi-tablet', 'bi-smartwatch',
|
|
'bi-camera-video', 'bi-camera', 'bi-printer', 'bi-keyboard', 'bi-mouse',
|
|
'bi-hdd', 'bi-hdd-rack', 'bi-hdd-stack', 'bi-server', 'bi-cpu', 'bi-memory',
|
|
'bi-disc', 'bi-usb-drive', 'bi-usb-plug', 'bi-ethernet', 'bi-router', 'bi-wifi',
|
|
'bi-shield-lock', 'bi-shield-check', 'bi-lock', 'bi-unlock', 'bi-plug',
|
|
'bi-battery', 'bi-battery-charging', 'bi-gear', 'bi-gear-fill', 'bi-tools',
|
|
'bi-wrench', 'bi-tag', 'bi-tags', 'bi-bookmark', 'bi-house-door-fill',
|
|
'bi-building', 'bi-person', 'bi-people',
|
|
];
|
|
|
|
function setIconPickerValue(input, val) {
|
|
input.value = val || '';
|
|
var container = input.closest('.icon-picker');
|
|
if (!container) return;
|
|
var preview = container.querySelector('.icon-picker-preview');
|
|
var label = container.querySelector('.icon-picker-label');
|
|
if (val) {
|
|
preview.className = 'bi ' + val + ' icon-picker-preview';
|
|
label.textContent = val;
|
|
label.classList.remove('text-body-secondary');
|
|
} else {
|
|
preview.className = 'bi icon-picker-preview';
|
|
label.textContent = 'Choose icon…';
|
|
label.classList.add('text-body-secondary');
|
|
}
|
|
}
|
|
|
|
function closeAllIconPickers() {
|
|
document.querySelectorAll('.icon-picker-menu.show').forEach(function (m) { m.classList.remove('show'); });
|
|
}
|
|
|
|
function initIconPickers() {
|
|
document.querySelectorAll('[data-icon-picker]').forEach(function (container) {
|
|
if (container.dataset.iconPickerInit) return;
|
|
container.dataset.iconPickerInit = '1';
|
|
|
|
var input = container.querySelector('input[type="hidden"]');
|
|
var toggle = container.querySelector('.icon-picker-toggle');
|
|
var menu = container.querySelector('.icon-picker-menu');
|
|
var search = container.querySelector('.icon-picker-search');
|
|
var grid = container.querySelector('.icon-picker-grid');
|
|
var clearBtn = container.querySelector('.icon-picker-clear');
|
|
|
|
function renderGrid(filter) {
|
|
filter = (filter || '').toLowerCase();
|
|
var list = COMMON_ICONS.filter(function (ic) { return !filter || ic.indexOf(filter) !== -1; });
|
|
grid.innerHTML = list.map(function (ic) {
|
|
return '<button type="button" class="icon-picker-item" data-icon="' + ic + '" title="' + ic + '"><i class="bi ' + ic + '"></i></button>';
|
|
}).join('') || '<div class="text-body-secondary small p-2">No matches</div>';
|
|
}
|
|
|
|
toggle.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
var isOpen = menu.classList.contains('show');
|
|
closeAllIconPickers();
|
|
if (!isOpen) {
|
|
menu.classList.add('show');
|
|
search.value = '';
|
|
renderGrid('');
|
|
search.focus();
|
|
}
|
|
});
|
|
|
|
search.addEventListener('input', function () { renderGrid(search.value.trim()); });
|
|
|
|
grid.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.icon-picker-item');
|
|
if (!btn) return;
|
|
setIconPickerValue(input, btn.dataset.icon);
|
|
menu.classList.remove('show');
|
|
});
|
|
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', function () {
|
|
setIconPickerValue(input, '');
|
|
menu.classList.remove('show');
|
|
});
|
|
}
|
|
|
|
renderGrid('');
|
|
setIconPickerValue(input, input.value);
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', function (e) {
|
|
if (!e.target.closest('.icon-picker')) closeAllIconPickers();
|
|
});
|
|
|
|
initIconPickers();
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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 = '';
|
|
setIconPickerValue(document.getElementById('folderFormIcon'), '');
|
|
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;
|
|
setIconPickerValue(document.getElementById('folderFormIcon'), 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Move asset modal
|
|
// ---------------------------------------------------------------------
|
|
|
|
var moveAssetModalEl = document.getElementById('moveAssetModal');
|
|
var moveAssetModal = (moveAssetModalEl && window.bootstrap) ? new bootstrap.Modal(moveAssetModalEl) : null;
|
|
|
|
async function openMoveAssetModal(id) {
|
|
if (!moveAssetModal) return;
|
|
moveAssetModalEl.dataset.assetId = id;
|
|
var sel = document.getElementById('moveAssetDestination');
|
|
sel.innerHTML = '<option value="">Inventory (top level)</option>';
|
|
try {
|
|
var opts = await apiFetch('/app/folders/options');
|
|
(opts || []).forEach(function (o) {
|
|
var indent = '';
|
|
for (var i = 0; i < o.depth; i++) indent += ' ';
|
|
var opt = document.createElement('option');
|
|
opt.value = o.id;
|
|
opt.innerHTML = indent + escapeHtml(o.path);
|
|
sel.appendChild(opt);
|
|
});
|
|
} catch (err) { /* fall back to top-level-only */ }
|
|
moveAssetModal.show();
|
|
}
|
|
|
|
var confirmMoveAssetBtn = document.getElementById('confirmMoveAssetBtn');
|
|
if (confirmMoveAssetBtn) {
|
|
confirmMoveAssetBtn.addEventListener('click', async function () {
|
|
var id = moveAssetModalEl.dataset.assetId;
|
|
var val = document.getElementById('moveAssetDestination').value;
|
|
try {
|
|
await apiFetch('/app/assets/' + id + '/move', { method: 'POST', json: { folder_id: val ? Number(val) : null } });
|
|
moveAssetModal.hide();
|
|
await refreshWholeTree();
|
|
await navigateAndSelect('asset', id);
|
|
} catch (err) {
|
|
alert('Move failed: ' + err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Asset form (create / edit) - shown in a modal
|
|
// ---------------------------------------------------------------------
|
|
|
|
var assetModalEl = document.getElementById('assetModal');
|
|
var assetModal = (assetModalEl && window.bootstrap) ? new bootstrap.Modal(assetModalEl) : null;
|
|
|
|
function showAssetModal(html) {
|
|
if (!assetModal) return;
|
|
document.getElementById('assetModalBody').innerHTML = html;
|
|
var form = document.getElementById('assetForm');
|
|
document.getElementById('assetModalTitle').textContent = form.dataset.title;
|
|
document.getElementById('assetModalSubmit').textContent = form.dataset.mode === 'edit' ? 'Save Changes' : 'Create Asset';
|
|
assetModal.show();
|
|
}
|
|
|
|
async function loadAssetForm(opts) {
|
|
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 : ''));
|
|
showAssetModal(html);
|
|
}
|
|
|
|
async function loadAssetEditForm(id) {
|
|
var html = await fetchText('/app/panel/assets/' + id + '/edit');
|
|
showAssetModal(html);
|
|
}
|
|
|
|
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 });
|
|
if (assetModal) assetModal.hide();
|
|
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) {
|
|
if (!confirm('Delete this asset? 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);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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 importModalEl = document.getElementById('importModal');
|
|
var importModal = (importModalEl && window.bootstrap) ? new bootstrap.Modal(importModalEl) : null;
|
|
|
|
function openImportModal(folderId) {
|
|
if (!importModal) return;
|
|
document.getElementById('importFolderId').value = folderId || '';
|
|
document.getElementById('importResult').innerHTML = '';
|
|
var fileInput = document.getElementById('importFile');
|
|
if (fileInput) fileInput.value = '';
|
|
importModal.show();
|
|
}
|
|
|
|
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…</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 + drag/drop 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; }
|
|
|
|
var toggle = e.target.closest('.tree-toggle');
|
|
if (toggle) { e.stopPropagation(); toggleFolder(toggle); 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; }
|
|
});
|
|
|
|
mainPanel.addEventListener('change', function (e) {
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// Draggable rows within the panel (subfolder list / asset tables) drop
|
|
// onto the tree only; the tree pane is the primary drag surface.
|
|
}
|
|
|
|
// The asset create/edit form lives inside #assetModal (injected fresh
|
|
// each time it's opened), so its dynamic bits are delegated from the
|
|
// modal element itself rather than from #main-panel.
|
|
if (assetModalEl) {
|
|
assetModalEl.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;
|
|
}
|
|
});
|
|
assetModalEl.addEventListener('submit', function (e) {
|
|
if (e.target.id === 'assetForm') {
|
|
e.preventDefault();
|
|
submitAssetForm(e.target);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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');
|
|
}
|
|
});
|
|
}
|
|
})();
|