diff --git a/internal/assets/csv.go b/internal/assets/csv.go
index c666144..93cef71 100644
--- a/internal/assets/csv.go
+++ b/internal/assets/csv.go
@@ -25,6 +25,13 @@ const (
// ImportOptions controls CSV import behavior.
type ImportOptions struct {
CreateMissingFolders bool
+
+ // TargetFolderID, when set, imports every row directly into this
+ // folder and ignores any Folder column in the CSV entirely (used for
+ // the folder-scoped "import here" context menu action). When nil,
+ // each row's folder is resolved from its Folder column instead
+ // (global import), optionally creating missing folders.
+ TargetFolderID *int64
}
type ImportResult struct {
@@ -157,8 +164,9 @@ func (s *Store) Import(ctx context.Context, r io.Reader, opts ImportOptions,
}
var folderID *int64
- folderPath := get(row, colFolder)
- if folderPath != "" {
+ if opts.TargetFolderID != nil {
+ folderID = opts.TargetFolderID
+ } else if folderPath := get(row, colFolder); folderPath != "" {
id, err := s.resolveFolderPath(ctx, lk, folderPath, opts.CreateMissingFolders)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("row %d: folder %q: %v", rowNum, folderPath, err))
diff --git a/internal/statuses/csv.go b/internal/statuses/csv.go
new file mode 100644
index 0000000..41beb62
--- /dev/null
+++ b/internal/statuses/csv.go
@@ -0,0 +1,99 @@
+package statuses
+
+import (
+ "context"
+ "encoding/csv"
+ "fmt"
+ "io"
+ "strings"
+
+ "inventree/internal/models"
+)
+
+type ImportResult struct {
+ Created int
+ Updated int
+ Errors []string
+}
+
+// Import creates or updates statuses from CSV data with "Name" and "Color"
+// columns (case-insensitive header names, Color optional). Existing
+// statuses are matched and updated by name (case-insensitive); everything
+// else is created.
+func (s *Store) Import(ctx context.Context, r io.Reader) (*ImportResult, error) {
+ existing, err := s.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ byName := map[string]int64{}
+ for _, st := range existing {
+ byName[strings.ToLower(st.Name)] = st.ID
+ }
+
+ cr := csv.NewReader(r)
+ cr.FieldsPerRecord = -1
+ header, err := cr.Read()
+ if err != nil {
+ if err == io.EOF {
+ return &ImportResult{}, nil
+ }
+ return nil, fmt.Errorf("read CSV header: %w", err)
+ }
+ colIdx := map[string]int{}
+ for i, h := range header {
+ colIdx[strings.ToLower(strings.TrimSpace(h))] = i
+ }
+ get := func(row []string, key string) string {
+ idx, ok := colIdx[key]
+ if !ok || idx >= len(row) {
+ return ""
+ }
+ return strings.TrimSpace(row[idx])
+ }
+
+ result := &ImportResult{}
+ rowNum := 1
+ for {
+ row, err := cr.Read()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read CSV row %d: %w", rowNum, err)
+ }
+ rowNum++
+
+ name := get(row, "name")
+ if name == "" {
+ result.Errors = append(result.Errors, fmt.Sprintf("row %d: name is required, skipped", rowNum))
+ continue
+ }
+ color := get(row, "color")
+
+ if id, ok := byName[strings.ToLower(name)]; ok {
+ st, err := s.Get(ctx, id)
+ if err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("row %d: %v", rowNum, err))
+ continue
+ }
+ if color != "" {
+ st.Color = color
+ }
+ if err := s.Update(ctx, st); err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("row %d: update failed: %v", rowNum, err))
+ continue
+ }
+ result.Updated++
+ continue
+ }
+
+ st := &models.Status{Name: name, Color: color}
+ if err := s.Create(ctx, st); err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("row %d: create failed: %v", rowNum, err))
+ continue
+ }
+ byName[strings.ToLower(name)] = st.ID
+ result.Created++
+ }
+ return result, nil
+}
diff --git a/internal/statuses/csv_test.go b/internal/statuses/csv_test.go
new file mode 100644
index 0000000..88224c6
--- /dev/null
+++ b/internal/statuses/csv_test.go
@@ -0,0 +1,76 @@
+package statuses_test
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "inventree/internal/database"
+ "inventree/internal/statuses"
+)
+
+func testDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dir := t.TempDir()
+ db, err := database.Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ return db
+}
+
+func TestImportCreatesAndUpdatesByName(t *testing.T) {
+ ctx := context.Background()
+ s := statuses.NewStore(testDB(t))
+
+ // "In Use" already exists from seed data; this should update its
+ // color rather than create a duplicate, and create "Awaiting Parts".
+ csvData := "Name,Color\nIn Use,primary\nAwaiting Parts,warning\n"
+ result, err := s.Import(ctx, strings.NewReader(csvData))
+ if err != nil {
+ t.Fatalf("import: %v", err)
+ }
+ if result.Created != 1 || result.Updated != 1 {
+ t.Fatalf("expected 1 created, 1 updated, got %+v", result)
+ }
+
+ list, err := s.List(ctx)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ var foundInUse, foundAwaiting bool
+ for _, st := range list {
+ if st.Name == "In Use" {
+ foundInUse = true
+ if st.Color != "primary" {
+ t.Errorf("expected In Use color updated to primary, got %s", st.Color)
+ }
+ }
+ if st.Name == "Awaiting Parts" {
+ foundAwaiting = true
+ if st.Color != "warning" {
+ t.Errorf("expected Awaiting Parts color warning, got %s", st.Color)
+ }
+ }
+ }
+ if !foundInUse || !foundAwaiting {
+ t.Fatalf("expected both statuses present, got %+v", list)
+ }
+}
+
+func TestImportSkipsRowsMissingName(t *testing.T) {
+ ctx := context.Background()
+ s := statuses.NewStore(testDB(t))
+
+ csvData := "Name,Color\n,primary\n"
+ result, err := s.Import(ctx, strings.NewReader(csvData))
+ if err != nil {
+ t.Fatalf("import: %v", err)
+ }
+ if result.Created != 0 || len(result.Errors) != 1 {
+ t.Fatalf("expected 0 created and 1 error, got %+v", result)
+ }
+}
diff --git a/internal/web/app.go b/internal/web/app.go
index 3e6ccf2..696f10a 100644
--- a/internal/web/app.go
+++ b/internal/web/app.go
@@ -58,7 +58,7 @@ func NewApp(db *sql.DB, log *slog.Logger, secureCookies bool) (*App, error) {
SecureCookies: secureCookies,
}
- tmpl, err := loadTemplates()
+ tmpl, err := loadTemplates(app)
if err != nil {
return nil, err
}
diff --git a/internal/web/handlers_csv.go b/internal/web/handlers_csv.go
index 96d51a9..de7b5d4 100644
--- a/internal/web/handlers_csv.go
+++ b/internal/web/handlers_csv.go
@@ -30,6 +30,11 @@ func (a *App) handleExportCSV(w http.ResponseWriter, r *http.Request) {
}
}
+// handleImportCSV backs the folder-scoped "Import CSV..." context-menu
+// action: every row imports directly into the given folder_id, so the CSV
+// needs no Folder column at all. It's only ever invoked from within a
+// specific folder (never at the top level) and responds with JSON since
+// it's called via fetch from the tree/panel context menu.
func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseMultipartForm(32 << 20); err != nil {
@@ -43,7 +48,10 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()
- createMissing := r.FormValue("create_missing_folders") == "1"
+ opts := assets.ImportOptions{}
+ if folderID, err := formOptionalID(r, "folder_id"); err == nil && folderID != nil {
+ opts.TargetFolderID = folderID
+ }
itemTypes, err := a.ItemTypes.List(ctx, true)
if err != nil {
@@ -61,7 +69,7 @@ func (a *App) handleImportCSV(w http.ResponseWriter, r *http.Request) {
return
}
- result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
+ result, err := a.Assets.Import(ctx, file, opts, itemTypes, statusList, userList)
if err != nil {
jsonErr(w, http.StatusBadRequest, err)
return
diff --git a/internal/web/handlers_settings_import.go b/internal/web/handlers_settings_import.go
new file mode 100644
index 0000000..be7470b
--- /dev/null
+++ b/internal/web/handlers_settings_import.go
@@ -0,0 +1,95 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "inventree/internal/assets"
+ "inventree/internal/models"
+)
+
+type settingsImportPageData struct {
+ User *models.User
+ Kind string
+ Imported int
+ Updated int
+ Errs int
+ Error string
+}
+
+// handleSettingsImportPage backs the admin "Import / Export" settings page:
+// global CSV import for assets (with a Folder column, optionally
+// auto-creating missing folders) and for statuses, plus format examples.
+// This is distinct from the folder-scoped "Import CSV..." context-menu
+// action in the tree, which never needs a Folder column.
+func (a *App) handleSettingsImportPage(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ a.Render(w, http.StatusOK, "settings_import", settingsImportPageData{
+ User: currentUser(r),
+ Kind: q.Get("kind"),
+ Imported: queryInt(r, "imported", 0),
+ Updated: queryInt(r, "updated", 0),
+ Errs: queryInt(r, "errs", 0),
+ Error: q.Get("error"),
+ })
+}
+
+func (a *App) handleSettingsImportAssets(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
+ http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+ defer file.Close()
+
+ createMissing := r.FormValue("create_missing_folders") == "1"
+
+ itemTypes, err := a.ItemTypes.List(ctx, true)
+ if err != nil {
+ http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+ statusList, err := a.Statuses.List(ctx)
+ if err != nil {
+ http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+ userList, err := a.Users.List(ctx)
+ if err != nil {
+ http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+
+ result, err := a.Assets.Import(ctx, file, assets.ImportOptions{CreateMissingFolders: createMissing}, itemTypes, statusList, userList)
+ if err != nil {
+ http.Redirect(w, r, "/settings/import?kind=assets&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, fmt.Sprintf("/settings/import?kind=assets&imported=%d&updated=%d&errs=%d", result.Created, result.Updated, len(result.Errors)), http.StatusSeeOther)
+}
+
+func (a *App) handleSettingsImportStatuses(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(10 << 20); err != nil {
+ http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+ defer file.Close()
+
+ result, err := a.Statuses.Import(r.Context(), file)
+ if err != nil {
+ http.Redirect(w, r, "/settings/import?kind=statuses&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, fmt.Sprintf("/settings/import?kind=statuses&imported=%d&updated=%d&errs=%d", result.Created, result.Updated, len(result.Errors)), http.StatusSeeOther)
+}
diff --git a/internal/web/helpers.go b/internal/web/helpers.go
index 67dba2a..47a522d 100644
--- a/internal/web/helpers.go
+++ b/internal/web/helpers.go
@@ -25,6 +25,20 @@ func queryOptionalID(r *http.Request, name string) (*int64, error) {
return &id, nil
}
+// formOptionalID parses a POST form/multipart-form field (already parsed via
+// ParseForm/ParseMultipartForm) as *int64; empty or missing returns nil.
+func formOptionalID(r *http.Request, name string) (*int64, error) {
+ v := r.FormValue(name)
+ if v == "" {
+ return nil, nil
+ }
+ id, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ return &id, nil
+}
+
func queryInt64(r *http.Request, name string) int64 {
v := r.URL.Query().Get(name)
if v == "" {
diff --git a/internal/web/render.go b/internal/web/render.go
index a6729c1..3df684a 100644
--- a/internal/web/render.go
+++ b/internal/web/render.go
@@ -1,6 +1,7 @@
package web
import (
+ "context"
"fmt"
"html/template"
"io/fs"
@@ -94,8 +95,21 @@ type templateSet struct {
// each page can define a block named "content" without colliding with the
// others (html/template templates sharing one *template.Template must have
// unique names, but isolated clones may reuse the same name freely).
-func loadTemplates() (*templateSet, error) {
- shared := template.New("root").Funcs(templateFuncs)
+func loadTemplates(a *App) (*templateSet, error) {
+ // siteName is bound per-App (rather than living in the package-level
+ // templateFuncs) since it needs a live DB read through a.Settings; it's
+ // used by layout.html for the
and navbar brand text.
+ appFuncs := template.FuncMap{
+ "siteName": func() string {
+ name, err := a.Settings.Get(context.Background(), "site_name", "inventree")
+ if err != nil || name == "" {
+ return "inventree"
+ }
+ return name
+ },
+ }
+
+ shared := template.New("root").Funcs(templateFuncs).Funcs(appFuncs)
shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html")
if err != nil {
@@ -124,7 +138,7 @@ func loadTemplates() (*templateSet, error) {
pages[name] = clone
}
- login := template.New("login-root").Funcs(templateFuncs)
+ login := template.New("login-root").Funcs(templateFuncs).Funcs(appFuncs)
login, err = login.ParseFS(content.TemplatesFS, "templates/login.html")
if err != nil {
return nil, fmt.Errorf("parse login: %w", err)
diff --git a/internal/web/router.go b/internal/web/router.go
index 254a24b..8b9ec05 100644
--- a/internal/web/router.go
+++ b/internal/web/router.go
@@ -80,6 +80,10 @@ func (a *App) Router() http.Handler {
mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}", a.requireAdmin(a.handleFieldUpdate))
mux.HandleFunc("POST /settings/item-types/{id}/fields/{fieldID}/delete", a.requireAdmin(a.handleFieldDelete))
+ mux.HandleFunc("GET /settings/import", a.requireAdmin(a.handleSettingsImportPage))
+ mux.HandleFunc("POST /settings/import/assets", a.requireAdmin(a.handleSettingsImportAssets))
+ mux.HandleFunc("POST /settings/import/statuses", a.requireAdmin(a.handleSettingsImportStatuses))
+
mux.HandleFunc("GET /settings/users", a.requireAdmin(a.handleUsersList))
mux.HandleFunc("GET /settings/users/new", a.requireAdmin(a.handleUserNewForm))
mux.HandleFunc("POST /settings/users", a.requireAdmin(a.handleUserCreate))
diff --git a/web/static/css/app.css b/web/static/css/app.css
index 11eac9b..656c9b5 100644
--- a/web/static/css/app.css
+++ b/web/static/css/app.css
@@ -154,23 +154,91 @@ ul.tree-children {
cursor: default;
}
-.stat-card .card-body {
- text-align: center;
+.panel-content {
+ max-width: 1100px;
}
-.stat-value {
- font-size: 1.4rem;
- font-weight: 600;
- line-height: 1.2;
+.panel-blank {
+ max-width: none;
}
-.stat-label {
+/* Icon picker: a searchable dropdown of common Bootstrap Icons, standing
+ in for a free-text icon class field. */
+.icon-picker {
+ position: relative;
+}
+
+.icon-picker-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ text-align: left;
+}
+
+.icon-picker-preview {
+ font-size: 1.1rem;
+ width: 1.1rem;
+ flex-shrink: 0;
+}
+
+.icon-picker-label {
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.icon-picker-caret {
+ flex-shrink: 0;
font-size: 0.75rem;
color: #6c757d;
}
-.panel-content {
- max-width: 1100px;
+.icon-picker-menu {
+ display: none;
+ position: absolute;
+ z-index: 1085;
+ top: 100%;
+ left: 0;
+ margin-top: 0.25rem;
+ width: 280px;
+ max-width: 90vw;
+ background-color: #fff;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 0.375rem;
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
+ padding: 0.5rem;
+}
+
+.icon-picker-menu.show {
+ display: block;
+}
+
+.icon-picker-grid {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 0.25rem;
+ max-height: 190px;
+ overflow-y: auto;
+ margin-top: 0.5rem;
+}
+
+.icon-picker-item {
+ border: 1px solid transparent;
+ background: none;
+ border-radius: 0.25rem;
+ padding: 0.4rem;
+ font-size: 1.1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #212529;
+}
+
+.icon-picker-item:hover,
+.icon-picker-item:focus {
+ background-color: #f1f3f5;
+ border-color: rgba(0, 0, 0, 0.1);
}
.asset-row, .folder-link, .asset-link {
diff --git a/web/static/js/app.js b/web/static/js/app.js
index 6a2dcf9..2f1b736 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -240,6 +240,7 @@
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) {
@@ -261,6 +262,9 @@
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) {
@@ -271,9 +275,6 @@
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;
}
@@ -283,10 +284,13 @@
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(); } });
+ 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;
}
@@ -344,6 +348,105 @@
});
}
+ // ---------------------------------------------------------------------
+ // 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 '';
+ }).join('') || 'No matches
';
+ }
+
+ 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
// ---------------------------------------------------------------------
@@ -357,7 +460,7 @@
document.getElementById('folderFormParentId').value = (opts && opts.parentId) || '';
document.getElementById('folderFormName').value = '';
document.getElementById('folderFormDescription').value = '';
- document.getElementById('folderFormIcon').value = '';
+ setIconPickerValue(document.getElementById('folderFormIcon'), '');
document.getElementById('folderFormSortOrder').value = '0';
document.getElementById('folderModalTitle').textContent = 'New Folder';
document.getElementById('folderFormError').classList.add('d-none');
@@ -372,7 +475,7 @@
document.getElementById('folderFormParentId').value = '';
document.getElementById('folderFormName').value = f.Name;
document.getElementById('folderFormDescription').value = f.Description;
- document.getElementById('folderFormIcon').value = f.Icon;
+ 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');
@@ -658,6 +761,15 @@
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) {
diff --git a/web/templates/layout.html b/web/templates/layout.html
index 95cf7fe..93351eb 100644
--- a/web/templates/layout.html
+++ b/web/templates/layout.html
@@ -3,16 +3,16 @@
- inventree
+ {{siteName}}