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:
@@ -5,9 +5,9 @@ import "context"
|
||||
// FolderOption is a flattened, indented folder choice for <select> dropdowns
|
||||
// (asset create/edit, move-to pickers).
|
||||
type FolderOption struct {
|
||||
ID int64
|
||||
Path string
|
||||
Depth int
|
||||
ID int64 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
|
||||
// folderOptions returns every folder as a flat, depth-first ordered list
|
||||
|
||||
@@ -8,6 +8,17 @@ import (
|
||||
"inventree/internal/models"
|
||||
)
|
||||
|
||||
// handleFolderOptions returns every folder as a flat, indented list for
|
||||
// generic "move to..." pickers (e.g. the asset move context-menu action).
|
||||
func (a *App) handleFolderOptions(w http.ResponseWriter, r *http.Request) {
|
||||
opts, err := a.folderOptions(r.Context())
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, opts)
|
||||
}
|
||||
|
||||
func (a *App) handleFolderGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
|
||||
@@ -221,9 +221,7 @@ func (a *App) handlePanelFolder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// AssetPanelData backs the main panel when an asset is selected (view mode).
|
||||
type AssetPanelData struct {
|
||||
Asset *models.Asset
|
||||
CanEdit bool
|
||||
FolderOptions []FolderOption
|
||||
Asset *models.Asset
|
||||
}
|
||||
|
||||
func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -238,12 +236,7 @@ func (a *App) handlePanelAsset(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
opts, err := a.folderOptions(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.RenderFragment(w, http.StatusOK, "panel_asset", AssetPanelData{Asset: asset, CanEdit: currentUser(r).IsAdmin(), FolderOptions: opts})
|
||||
a.RenderFragment(w, http.StatusOK, "panel_asset", AssetPanelData{Asset: asset})
|
||||
}
|
||||
|
||||
// AssetFormData backs both the "new asset" and "edit asset" forms.
|
||||
|
||||
@@ -40,6 +40,7 @@ func (a *App) Router() http.Handler {
|
||||
mux.HandleFunc("GET /app/search", a.requireLogin(a.handleSearch))
|
||||
|
||||
// Folder mutations (JSON in/out)
|
||||
mux.HandleFunc("GET /app/folders/options", a.requireAdmin(a.handleFolderOptions))
|
||||
mux.HandleFunc("GET /app/folders/{id}", a.requireAdmin(a.handleFolderGet))
|
||||
mux.HandleFunc("POST /app/folders", a.requireAdmin(a.handleFolderCreate))
|
||||
mux.HandleFunc("PATCH /app/folders/{id}", a.requireAdmin(a.handleFolderUpdate))
|
||||
|
||||
+21
-15
@@ -129,25 +129,31 @@ ul.tree-children {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-tree-action {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6c757d;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.btn-tree-action:hover {
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.tree-node.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Generic right-click context menu (tree + panels feel like a native app:
|
||||
most actions live here instead of on-screen buttons). */
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 1080;
|
||||
min-width: 190px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctx-menu .dropdown-item i {
|
||||
width: 1.1rem;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
[data-context="asset-header"],
|
||||
[data-context="folder-header"] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.stat-card .card-body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
+214
-78
@@ -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 += ' ';
|
||||
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.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -2,19 +2,13 @@
|
||||
<div class="inventory-shell d-flex">
|
||||
<aside id="tree-pane" class="tree-pane border-end d-flex flex-column">
|
||||
<div class="p-2 border-bottom">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="search" id="tree-search" class="form-control" placeholder="Search inventory…">
|
||||
<button class="btn btn-outline-secondary" id="btn-expand-all" title="Expand all folders"><i class="bi bi-arrows-expand"></i></button>
|
||||
<button class="btn btn-outline-secondary" id="btn-collapse-all" title="Collapse all folders"><i class="bi bi-arrows-collapse"></i></button>
|
||||
</div>
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-expand-all" title="Expand all folders"><i class="bi bi-arrows-expand"></i> Expand</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-collapse-all" title="Collapse all folders"><i class="bi bi-arrows-collapse"></i> Collapse</button>
|
||||
{{if .User.IsAdmin}}
|
||||
<button class="btn btn-sm btn-primary ms-auto" id="btn-new-root-folder" data-parent-id="" title="New top-level folder">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-text">Right-click the tree to add, rename, or delete.</div>
|
||||
</div>
|
||||
<div class="tree-scroll flex-fill overflow-auto">
|
||||
<ul class="tree list-unstyled mb-0" id="tree-root">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{$last := sub (len .) 1}}
|
||||
{{range $i, $b := .}}
|
||||
{{if eq $i $last}}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{$b.Name}}</li>
|
||||
<li class="breadcrumb-item active" aria-current="page" data-context="folder-header" data-id="{{$b.ID}}">{{$b.Name}}</li>
|
||||
{{else}}
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{$b.ID}}">{{$b.Name}}</a></li>
|
||||
{{end}}
|
||||
|
||||
@@ -77,6 +77,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move asset -->
|
||||
<div class="modal fade" id="moveAssetModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Move Asset</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label">Destination Folder</label>
|
||||
<select class="form-select" id="moveAssetDestination">
|
||||
<option value="">Inventory (top level)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmMoveAssetBtn">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV -->
|
||||
<div class="modal fade" id="importModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
{{define "panel_asset"}}
|
||||
<div class="panel-content p-3" data-asset-id="{{.Asset.ID}}">
|
||||
<div class="d-flex align-items-start mb-2">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
{{range .Asset.FolderPath}}
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{.ID}}">{{.Name}}</a></li>
|
||||
{{end}}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{.Asset.Name}}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{{if .CanEdit}}
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="edit-asset" data-id="{{.Asset.ID}}"><i class="bi bi-pencil"></i> Edit</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="move-asset" data-bs-toggle="modal" data-bs-target="#moveAssetModal-{{.Asset.ID}}"><i class="bi bi-arrows-move"></i> Move</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="duplicate-asset" data-id="{{.Asset.ID}}"><i class="bi bi-files"></i> Duplicate</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-action="delete-asset" data-id="{{.Asset.ID}}" data-name="{{.Asset.Name}}"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="root">Inventory</a></li>
|
||||
{{range .Asset.FolderPath}}
|
||||
<li class="breadcrumb-item"><a href="#" class="breadcrumb-link" data-type="folder" data-id="{{.ID}}">{{.Name}}</a></li>
|
||||
{{end}}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{.Asset.Name}}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-3 mt-2" data-context="asset-header" data-id="{{.Asset.ID}}" title="Right-click for actions">
|
||||
<i class="bi {{iconOr .Asset.ItemTypeIcon "bi-box-seam"}} fs-3 text-body-secondary"></i>
|
||||
<div>
|
||||
<h4 class="mb-0">{{.Asset.Name}}</h4>
|
||||
@@ -75,30 +65,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .CanEdit}}
|
||||
<div class="modal fade" id="moveAssetModal-{{.Asset.ID}}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Move "{{.Asset.Name}}"</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label">Destination Folder</label>
|
||||
<select class="form-select" id="moveAssetDestination-{{.Asset.ID}}">
|
||||
<option value="">Inventory (top level)</option>
|
||||
{{range .FolderOptions}}
|
||||
<option value="{{.ID}}">{{range seq .Depth}} {{end}}{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-action="confirm-move-asset" data-id="{{.Asset.ID}}" data-select="moveAssetDestination-{{.Asset.ID}}">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
{{define "panel_folder"}}
|
||||
<div class="panel-content p-3" data-folder-id="{{.Folder.ID}}">
|
||||
<div class="d-flex align-items-start mb-2">
|
||||
{{template "breadcrumb" .Breadcrumb}}
|
||||
<div class="ms-auto d-flex gap-2 admin-only">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="new-subfolder" data-id="{{.Folder.ID}}"><i class="bi bi-folder-plus"></i> Subfolder</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-action="rename-folder" data-id="{{.Folder.ID}}" data-name="{{.Folder.Name}}" data-description="{{.Folder.Description}}" data-icon="{{.Folder.Icon}}" data-sort-order="{{.Folder.SortOrder}}"><i class="bi bi-pencil"></i> Rename</button>
|
||||
<button class="btn btn-sm btn-outline-danger" data-action="delete-folder" data-id="{{.Folder.ID}}"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content p-3" data-panel-type="folder" data-folder-id="{{.Folder.ID}}">
|
||||
{{template "breadcrumb" .Breadcrumb}}
|
||||
|
||||
<h4 class="mb-1"><i class="bi {{iconOr .Folder.Icon "bi-folder-fill"}} text-warning-emphasis me-1"></i>{{.Folder.Name}}</h4>
|
||||
<h4 class="mb-1" data-context="folder-header" data-id="{{.Folder.ID}}">
|
||||
<i class="bi {{iconOr .Folder.Icon "bi-folder-fill"}} text-warning-emphasis me-1"></i>{{.Folder.Name}}
|
||||
</h4>
|
||||
{{if .Folder.Description}}<p class="text-body-secondary">{{.Folder.Description}}</p>{{end}}
|
||||
|
||||
{{if .Subfolders}}
|
||||
@@ -38,10 +33,6 @@
|
||||
</select>
|
||||
<input type="search" class="form-control form-control-sm" name="q" value="{{.Query}}" placeholder="Filter assets…" style="width:160px">
|
||||
</form>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/export/assets.csv?folder_id={{.Folder.ID}}&recursive=1"><i class="bi bi-download"></i> Export</a>
|
||||
<button class="btn btn-sm btn-primary admin-only" data-action="new-asset" data-folder-id="{{.Folder.ID}}"><i class="bi bi-plus-lg"></i> Add Asset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Assets}}
|
||||
@@ -74,7 +65,7 @@
|
||||
</nav>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-body-secondary">No assets in this folder yet.</p>
|
||||
<p class="text-body-secondary">No assets in this folder yet. <span class="admin-only">Right-click here to add one.</span></p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{{define "panel_root"}}
|
||||
<div class="panel-content p-3">
|
||||
<div class="panel-content p-3" data-panel-type="root">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item active" aria-current="page">Inventory</li>
|
||||
<li class="breadcrumb-item active" aria-current="page" data-type="root">Inventory</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@@ -62,14 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h6 class="mb-0">Top-Level Folders</h6>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/export/assets.csv"><i class="bi bi-download"></i> Export CSV</a>
|
||||
<button class="btn btn-sm btn-outline-secondary admin-only" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
|
||||
<button class="btn btn-sm btn-primary admin-only" id="btn-new-folder-root"><i class="bi bi-folder-plus"></i> New Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="mb-2">Top-Level Folders</h6>
|
||||
|
||||
{{if .Folders}}
|
||||
<div class="list-group mb-3">
|
||||
@@ -83,7 +76,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-body-secondary">No folders yet. {{if true}}<span class="admin-only">Create one to start organizing your inventory.</span>{{end}}</p>
|
||||
<p class="text-body-secondary">No folders yet. <span class="admin-only">Right-click here (or the tree) to create one.</span></p>
|
||||
{{end}}
|
||||
|
||||
{{if .UnfiledAssets}}
|
||||
|
||||
@@ -12,11 +12,6 @@
|
||||
{{end}}
|
||||
<i class="bi {{iconOr .Icon "bi-folder-fill"}} tree-icon text-warning-emphasis"></i>
|
||||
<span class="tree-label">{{.Name}}</span>
|
||||
<span class="tree-actions admin-only">
|
||||
<button type="button" class="btn-tree-action" data-action="menu" data-id="{{.ID}}" data-type="folder" title="Actions">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul class="tree-children list-unstyled{{if not .Expanded}} d-none{{end}}" data-expanded="{{.Expanded}}" data-loaded="{{.Expanded}}">
|
||||
{{if .Expanded}}{{template "tree_children" .Children}}{{end}}
|
||||
|
||||
Reference in New Issue
Block a user