Replace on-screen action buttons with right-click context menus

The tree and main panels now behave like a native file manager: right
click (or the keyboard Menu key) on a folder, asset, breadcrumb crumb, or
empty tree/panel background brings up the relevant actions instead of a
row of always-visible buttons.

- New generic showContextMenu() in app.js with per-target-kind menu
  builders (folder/asset/tree background/panel background), gated by
  admin role the same way the old buttons were.
- Removed: the "..." tree-row action button, and the New Folder/Add
  Asset/Rename/Delete/Edit/Move/Duplicate/Import/Export buttons from the
  inventory toolbar and every panel. Expand All/Collapse All shrank to two
  icon-only buttons next to the search box; everything else creation- or
  mutation-related is right-click only.
- Replaced the per-asset embedded "Move" modal with one shared
  #moveAssetModal populated via a new GET /app/folders/options endpoint,
  since move can now be triggered from any asset row, not just its own
  detail view.
- Breadcrumb crumbs (including the current folder) and folder/asset table
  rows are now valid right-click targets too, not just tree rows.
This commit is contained in:
Owen
2026-08-17 15:24:32 -05:00
parent 218687a1ee
commit 4ceb1cbc03
13 changed files with 299 additions and 193 deletions
+214 -78
View File
@@ -1,5 +1,12 @@
// 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';
@@ -158,7 +165,7 @@
}
// ---------------------------------------------------------------------
// Tree context menu ("...")
// Generic right-click context menu
// ---------------------------------------------------------------------
var ctxMenu = null;
@@ -166,14 +173,8 @@
function ensureCtxMenu() {
if (ctxMenu) return ctxMenu;
ctxMenu = document.createElement('div');
ctxMenu.className = 'dropdown-menu tree-ctx-menu';
ctxMenu.style.position = 'fixed';
ctxMenu.className = 'dropdown-menu ctx-menu';
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;
}
@@ -182,30 +183,167 @@
if (ctxMenu) ctxMenu.style.display = 'none';
}
function openTreeContextMenu(btn) {
// 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();
var rect = btn.getBoundingClientRect();
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
menu.style.top = rect.bottom + 'px';
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.dataset.folderId = btn.dataset.id;
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) && !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);
}
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: '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;
}
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 });
if (isAdmin) {
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { if (importModal) importModal.show(); } });
}
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 }); } });
items.push({ divider: true });
items.push({ label: 'Import CSV…', icon: 'bi-upload', action: function () { if (importModal) importModal.show(); } });
}
var exportUrl = folderId ? ('/export/assets.csv?folder_id=' + folderId + '&recursive=1') : '/export/assets.csv';
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));
}
});
}
// ---------------------------------------------------------------------
// Folder create / rename modal
@@ -333,6 +471,48 @@
});
}
// ---------------------------------------------------------------------
// 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 += '&nbsp;&nbsp;&nbsp;';
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)
// ---------------------------------------------------------------------
@@ -407,8 +587,8 @@
}
}
async function deleteAsset(id, name) {
if (!confirm('Delete asset "' + name + '"? This cannot be undone.')) return;
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();
@@ -419,23 +599,6 @@
}
}
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
// ---------------------------------------------------------------------
@@ -493,6 +656,9 @@
// CSV import
// ---------------------------------------------------------------------
var importModalEl = document.getElementById('importModal');
var importModal = (importModalEl && window.bootstrap) ? new bootstrap.Modal(importModalEl) : null;
var importFormEl = document.getElementById('importForm');
if (importFormEl) {
importFormEl.addEventListener('submit', async function (e) {
@@ -520,21 +686,17 @@
}
// ---------------------------------------------------------------------
// Tree pane: click delegation
// 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; }
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);
@@ -618,30 +780,6 @@
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; }
});
@@ -680,10 +818,8 @@
}
});
// 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.
// Draggable rows within the panel (subfolder list / asset tables) drop
// onto the tree only; the tree pane is the primary drag surface.
}
// ---------------------------------------------------------------------