diff --git a/.gitignore b/.gitignore
index 96edeb38..9e9b141e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,8 @@
!/modules/web-worker-manager
/plugins/**
+!/plugins/analyze
+!/plugins/analyze/**
!/plugins/annotations
!/plugins/custom-pages
!/plugins/empaia
diff --git a/plugins/analyze/README.md b/plugins/analyze/README.md
new file mode 100644
index 00000000..f1abedf0
--- /dev/null
+++ b/plugins/analyze/README.md
@@ -0,0 +1,24 @@
+
+Analyze plugin
+========================
+
+Purpose
+-------
+Adds an "Analyze" tab to the AppBar with two actions: a "Run Recent →" anchor that opens a right-side recent-jobs panel, and "Create New App" which creates floating window with new app form.
+
+Files
+-----
+- `analyzeDropdown.mjs` - registers the tab and wires dropdown items.
+- `newAppForm.mjs` - the floating form used by "Create New App".
+
+How to use
+----------
+- Provide recent jobs by passing `params.recentJobs` or saving `recentJobs` via plugin options.
+- Handle job clicks by implementing `onJobClick({ index, label })` on the plugin instance.
+- Provide `params.onCreate` to receive form submission data from `NewAppForm`.
+
+Implementation notes
+--------------------
+- UI behaviors (menu, positioning, hover) are implemented in `SidePanel` (`setMenu`, `showNear`, `scheduleHide`, `cancelHide`) — reuse it for other flyouts.
+- `SidePanel.hide()` currently removes the element; consider switching to `display:none` if you need faster show/hide cycles.
+
diff --git a/plugins/analyze/analyzeDropdown.mjs b/plugins/analyze/analyzeDropdown.mjs
new file mode 100644
index 00000000..cc8741fa
--- /dev/null
+++ b/plugins/analyze/analyzeDropdown.mjs
@@ -0,0 +1,459 @@
+import { Dropdown } from "../../ui/classes/elements/dropdown.mjs";
+import { NewAppForm } from "./newAppForm.mjs";
+import { SidePanel } from "../../ui/classes/components/sidePanel.mjs";
+
+addPlugin('analyze', class extends XOpatPlugin {
+ constructor(id, params) {
+ super(id);
+ this.params = params || {};
+ // plugin-level stored recent jobs can be configured via params or saved options
+ this.recentJobs = this.getOption('recentJobs') || this.params.recentJobs || [];
+ }
+
+ pluginReady() {
+ const register = () => {
+
+ if (!window.USER_INTERFACE || !USER_INTERFACE.AppBar || !USER_INTERFACE.AppBar.menu) {
+
+ // retry shortly if AppBar not ready yet
+ return setTimeout(register, 50);
+ }
+
+ // safe translation helper: return translated value or fallback when missing
+ const tOr = (key, fallback) => {
+ if (typeof $?.t === 'function') {
+ try {
+ const translated = $.t(key);
+ if (translated && translated !== key) return translated;
+ } catch (e) { /* ignore and fallback */ }
+ }
+ return fallback;
+ };
+
+ const title = tOr('analyze.title', 'Analyze');
+ const tab = USER_INTERFACE.AppBar.addTab(
+ this.id, // ownerPluginId
+ title, // title (localized if available)
+ 'fa-magnifying-glass', // icon
+ [], // body
+ Dropdown // itemClass so Menu constructs plugin component
+ );
+
+
+ if (tab) {
+ const attachToggle = () => {
+ try {
+ const btnId = `${tab.parentId}-b-${tab.id}`;
+ const btnEl = document.getElementById(btnId);
+ if (!btnEl) return false;
+ let wrapper = btnEl.closest('.dropdown');
+ if (!wrapper) {
+ try {
+ const newWrapper = tab.create();
+ const parent = btnEl.parentElement;
+ if (parent) {
+ parent.insertBefore(newWrapper, btnEl);
+ btnEl.remove();
+ wrapper = newWrapper;
+ }
+ } catch (e) {
+ }
+ }
+
+ if (wrapper) {
+ const trigger = wrapper.querySelector('[tabindex]') || wrapper;
+ trigger.addEventListener('click', (e) => {
+ try {
+ wrapper.classList.toggle('dropdown-open');
+ if (!wrapper.classList.contains('dropdown-open')) {
+ try { tab.hideRecent?.(); } catch(_) {}
+ }
+ } catch(_) {}
+ e.stopPropagation();
+ });
+ return true;
+ }
+ } catch (e) { console.error('[analyze] attachToggle error', e); }
+ return false;
+ };
+ // Try immediate attach; if DOM not present yet, retry shortly
+ if (!attachToggle()) setTimeout(attachToggle, 50);
+ }
+
+ // Configure dropdown items using the Dropdown API
+ try {
+ if (tab && typeof tab.addItem === 'function') {
+ // create the 'recent' section but keep its title empty so no uppercase header is shown
+ try { tab.addSection({ id: 'recent', title: '' }); } catch (e) {}
+ // prefer a slightly wider dropdown to match previous styling
+ try { if (tab) { tab.widthClass = 'w-64'; if (tab._contentEl) tab._contentEl.classList.add('w-64'); } } catch(e) {}
+
+ // Only add a single anchor item for Run Recent; the detailed list appears in the SidePanel on hover
+ tab.addItem({
+ id: 'run-recent',
+ section: 'recent',
+ label: tOr('analyze.runRecent', 'Run Recent') + ' \u2192',
+ onClick: () => false,
+ });
+
+ // create a reusable SidePanel and attach delegated hover handlers to show it
+ try {
+ // let the panel size to its content by default (width: 'auto')
+ const side = new SidePanel({ id: `${this.id}-recent-panel`, width: 'auto', maxHeight: '70vh' });
+ const attachHover = () => {
+ try {
+ const content = tab._contentEl;
+ if (!content) return false;
+
+ // delegate to the 'run-recent' item inside the dropdown content
+ content.addEventListener('mouseover', (e) => {
+ const hit = e.target.closest && e.target.closest('[data-item-id]');
+ if (hit && hit.dataset && hit.dataset.itemId === 'run-recent') {
+ try {
+ // cancel any pending hide so we can reopen immediately
+ side.cancelHide?.();
+ const jobs = (this.recentJobs && this.recentJobs.length) ? this.recentJobs : ['Recent Job 1','Recent Job 2','Recent Job 3'];
+ // use SidePanel helper to build a menu and position the panel next to the anchor
+ side.setMenu(jobs, (it, idx) => {
+ try { if (typeof this.onJobClick === 'function') this.onJobClick({ index: idx, label: (typeof it === 'string' ? it : (it && it.label)) }); } catch(_){}
+ });
+ side.showNear(hit, { nudge: 1 });
+ try { tab.hideRecent = () => side.hide(); } catch(_) {}
+ } catch (err) { console.error('[analyze] show side panel error', err); }
+ }
+ });
+
+ content.addEventListener('mouseout', (e) => {
+ const related = e.relatedTarget;
+ if (!related || !related.closest || !related.closest(`#${side.id}`)) side.scheduleHide();
+ });
+ return true;
+ } catch (e) { console.error('[analyze] attachHover error', e); }
+ return false;
+ };
+ if (!attachHover()) setTimeout(attachHover, 50);
+ } catch (e) { /* ignore */ }
+
+
+
+ tab.addItem({
+ id: 'create-app',
+ label: tOr('analyze.createApp', 'Create New App'),
+ onClick: () => {
+ try {
+ const form = new NewAppForm({ onSubmit: (data) => {
+ try {
+ if (this.params.onCreate?.(data) !== false) {
+ USER_INTERFACE.Dialogs.show('Successfuly created new app');
+ }
+ }
+ catch (err) { console.error(err); }
+ }});
+ const win = form.showFloating({ title: tOr('analyze.createApp', 'Create New App'), width: 420, height: 360 });
+ if (!win) {
+ const overlayId = `${this.id}-newapp-overlay`;
+ USER_INTERFACE.Dialogs.showCustom(overlayId, 'New App', `
`, '', { allowClose: true });
+ const container = document.getElementById(overlayId)?.querySelector('.card-body');
+ if (container) form.attachTo(container);
+ }
+ } catch (e) { console.error('[analyze] create-app error', e); }
+ return false;
+ }
+ });
+
+ // Add Apps item: collapses dropdown and opens floating window listing apps
+ tab.addItem({
+ id: 'apps-list',
+ label: tOr('analyze.apps', 'Apps'),
+ onClick: async () => {
+ try {
+ this._collapseDropdown(tab);
+ await this._showAppsWindow(tOr);
+ } catch (e) {
+ console.error('[analyze] apps-list error', e);
+ }
+ return false;
+ }
+ });
+ }
+ } catch (e) { console.warn('[analyze] failed to configure dropdown items', e); }
+ // Close dropdowns when clicking away: attach a document-level click handler once per tab
+ const attachDocumentCloser = (t) => {
+ try {
+ if (!t || t.__analyzeDocCloserAttached) return;
+ const btnId = `${t.parentId}-b-${t.id}`;
+ const docHandler = (ev) => {
+ try {
+ const openWrappers = Array.from(document.querySelectorAll('.dropdown.dropdown-open'));
+ openWrappers.forEach((wrapper) => {
+ const btnEl = document.getElementById(btnId);
+ if (btnEl && (btnEl === ev.target || btnEl.contains(ev.target))) return;
+ try { wrapper.classList.remove('dropdown-open'); } catch(_) {}
+ });
+ try { t.hideRecent?.(); } catch(_) {}
+ } catch (_) { /* swallow */ }
+ };
+ document.addEventListener('click', docHandler, true);
+ const keyHandler = (ev) => { if (ev.key === 'Escape') { try { Array.from(document.querySelectorAll('.dropdown.dropdown-open')).forEach(w=>w.classList.remove('dropdown-open')); try { t.hideRecent?.(); } catch(_){} } catch(_){} } };
+ document.addEventListener('keydown', keyHandler, true);
+ t.__analyzeDocCloserAttached = true;
+ } catch (e) { /* ignore */ }
+ };
+ try { attachDocumentCloser(tab); } catch(e) { /* ignore */ }
+ };
+
+ register();
+ }
+
+ // Hardcoded case ID for now - should be made configurable
+ get _caseId() {
+ return '87fbb59a-3183-4d36-ab22-48f4e027d1f0';
+ }
+
+ _collapseDropdown(tab) {
+ try {
+ const btnId = `${tab.parentId}-b-${tab.id}`;
+ const btnEl = document.getElementById(btnId);
+ const wrapper = btnEl?.closest('.dropdown');
+ wrapper?.classList.remove('dropdown-open');
+ try { tab.hideRecent?.(); } catch(_) {}
+ } catch(_) {}
+ }
+
+ async _showAppsWindow(tOr) {
+ let items = [];
+ try {
+ const resp = await window.EmpaiaStandaloneJobs?.getApps?.();
+ items = Array.isArray(resp?.items) ? resp.items : [];
+ } catch (e) {
+ console.warn('[analyze] failed to fetch apps, showing empty list', e);
+ }
+
+ const { FloatingWindow } = await import('../../ui/classes/components/floatingWindow.mjs');
+ const fw = new FloatingWindow({
+ id: `${this.id}-apps-window`,
+ title: tOr('analyze.apps', 'Apps'),
+ width: 520,
+ height: 480
+ });
+ fw.attachTo(document.body);
+
+ const container = document.createElement('div');
+ container.className = 'p-2 space-y-3';
+
+ for (const [idx, app] of items.entries()) {
+ const card = this._createAppCard(app, idx, tOr);
+ container.appendChild(card);
+ }
+
+ if (!items.length) {
+ const empty = document.createElement('div');
+ empty.className = 'p-2 text-sm opacity-70';
+ empty.textContent = tOr('analyze.noApps', 'No apps available.');
+ container.appendChild(empty);
+ }
+
+ fw.setBody(container);
+ fw.focus();
+ }
+
+ _createAppCard(app, idx, tOr) {
+ const appId = app?.id || app?.app_id;
+ const wrap = document.createElement('div');
+ wrap.className = 'p-3 rounded-box bg-base-200 border border-base-300';
+
+ // Header with title and configure button
+ const header = document.createElement('div');
+ header.className = 'flex items-center justify-between';
+
+ const title = document.createElement('span');
+ title.className = 'font-medium';
+ title.textContent = app?.name_short || app?.name || `App ${idx + 1}`;
+ header.appendChild(title);
+
+ const configBtn = document.createElement('button');
+ configBtn.type = 'button';
+ configBtn.className = 'btn btn-xs btn-ghost';
+ configBtn.textContent = 'Configure';
+ header.appendChild(configBtn);
+ wrap.appendChild(header);
+
+ // Description
+ if (app?.store_description) {
+ const desc = document.createElement('div');
+ desc.className = 'text-xs opacity-70 mt-1';
+ desc.textContent = app.store_description;
+ wrap.appendChild(desc);
+ }
+
+ // Inputs section (hidden by default)
+ const inputsSection = document.createElement('div');
+ inputsSection.className = 'mt-2 hidden';
+ inputsSection.innerHTML = 'Loading inputs...
';
+ wrap.appendChild(inputsSection);
+
+ let inputsForm = null;
+ let inputsLoaded = false;
+
+ configBtn.addEventListener('click', async () => {
+ inputsSection.classList.toggle('hidden');
+ if (!inputsLoaded && !inputsSection.classList.contains('hidden')) {
+ inputsLoaded = true;
+ try {
+ const api = EmpationAPI.V3.get();
+ const examination = await api.examinations.create(this._caseId, appId);
+ const scope = await api.getScopeFrom(examination);
+ inputsForm = await this._buildInputsForm(appId, scope);
+ inputsSection.innerHTML = '';
+ inputsSection.appendChild(inputsForm.container);
+ } catch (e) {
+ inputsSection.innerHTML = `Failed to load inputs: ${e.message}
`;
+ }
+ }
+ });
+
+ // Actions row
+ const actions = document.createElement('div');
+ actions.className = 'flex items-center gap-2 mt-2';
+
+ const runBtn = document.createElement('button');
+ runBtn.type = 'button';
+ runBtn.className = 'btn btn-sm btn-primary';
+ runBtn.textContent = tOr('analyze.run', 'Run');
+
+ const status = document.createElement('span');
+ status.className = 'text-xs flex-1';
+ status.textContent = tOr('analyze.jobReady', 'Ready');
+
+ runBtn.addEventListener('click', async () => {
+ try {
+ runBtn.disabled = true;
+ status.textContent = tOr('analyze.jobStarting', 'Starting...');
+
+ const inputs = inputsForm?.getInputs?.() || {};
+ console.log('[analyze] Running job with inputs:', inputs);
+
+ const res = await window.EmpaiaStandaloneJobs?.createAndRunJob?.({
+ appId,
+ caseId: this._caseId,
+ mode: 'STANDALONE',
+ inputs
+ });
+
+ const isSuccess = res?.status === 'COMPLETED';
+ status.textContent = `${tOr('analyze.jobFinal', 'Status')}: ${res?.status || 'UNKNOWN'}`;
+ status.className = isSuccess ? 'text-xs flex-1 text-success' : 'text-xs flex-1 text-error';
+ console.log('[analyze] Job final:', res);
+ } catch (err) {
+ console.error('[analyze] Failed to run app job', err);
+ status.textContent = `Error: ${err?.message || err}`;
+ status.className = 'text-xs flex-1 text-error';
+ } finally {
+ runBtn.disabled = false;
+ }
+ });
+
+ actions.appendChild(runBtn);
+ actions.appendChild(status);
+ wrap.appendChild(actions);
+
+ return wrap;
+ }
+
+ async _buildInputsForm(appId, scope, mode = 'STANDALONE') {
+ const container = document.createElement('div');
+ container.className = 'space-y-2 mt-2';
+
+ try {
+ const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(appId, scope);
+ if (!ead) {
+ container.innerHTML = 'No EAD available
';
+ return { container, getInputs: () => ({}) };
+ }
+
+ const requiredInputs = window.EmpaiaStandaloneJobs?.getRequiredInputs?.(ead, mode) || [];
+ if (requiredInputs.length === 0) {
+ container.innerHTML = 'No inputs required
';
+ return { container, getInputs: () => ({}) };
+ }
+
+ let slides = [];
+ try {
+ slides = await window.EmpaiaStandaloneJobs?.getCaseSlides?.(this._caseId) || [];
+ } catch (e) {
+ console.warn('[analyze] Failed to fetch slides', e);
+ }
+
+ const inputFields = {};
+
+ for (const input of requiredInputs) {
+ const row = this._createInputRow(input, slides, inputFields);
+ container.appendChild(row);
+ }
+
+ const getInputs = () => {
+ const result = {};
+ for (const [key, el] of Object.entries(inputFields)) {
+ if (el.type === 'checkbox') {
+ result[key] = el.checked ? 'true' : 'false';
+ } else {
+ const val = el.value?.trim();
+ if (val) result[key] = val;
+ }
+ }
+ return result;
+ };
+
+ return { container, getInputs };
+ } catch (e) {
+ console.error('[analyze] Failed to build inputs form', e);
+ container.innerHTML = `Error: ${e.message}
`;
+ return { container, getInputs: () => ({}) };
+ }
+ }
+
+ _createInputRow(input, slides, inputFields) {
+ const row = document.createElement('div');
+ row.className = 'flex items-center gap-2';
+
+ const label = document.createElement('label');
+ label.className = 'text-xs font-medium min-w-20';
+ label.textContent = `${input.key} (${input.type})`;
+ row.appendChild(label);
+
+ let fieldEl;
+
+ if (input.type === 'wsi') {
+ fieldEl = document.createElement('select');
+ fieldEl.className = 'select select-xs select-bordered flex-1';
+ fieldEl.innerHTML = '';
+ slides.forEach(slide => {
+ const opt = document.createElement('option');
+ opt.value = slide.id;
+ opt.textContent = slide.local_id || slide.id;
+ fieldEl.appendChild(opt);
+ });
+ } else if (input.type === 'bool') {
+ fieldEl = document.createElement('input');
+ fieldEl.type = 'checkbox';
+ fieldEl.className = 'checkbox checkbox-xs';
+ } else if (input.type === 'integer' || input.type === 'float') {
+ fieldEl = document.createElement('input');
+ fieldEl.type = 'number';
+ fieldEl.className = 'input input-xs input-bordered flex-1';
+ if (input.type === 'float') fieldEl.step = 'any';
+ } else {
+ fieldEl = document.createElement('input');
+ fieldEl.type = 'text';
+ fieldEl.className = 'input input-xs input-bordered flex-1';
+ if (!['string'].includes(input.type)) {
+ fieldEl.placeholder = `${input.type} ID`;
+ }
+ }
+
+ inputFields[input.key] = fieldEl;
+ row.appendChild(fieldEl);
+
+ return row;
+ }
+});
\ No newline at end of file
diff --git a/plugins/analyze/include.json b/plugins/analyze/include.json
new file mode 100644
index 00000000..48d177df
--- /dev/null
+++ b/plugins/analyze/include.json
@@ -0,0 +1,10 @@
+{
+ "id": "analyze",
+ "name": "Analyze dropdown in Main Menu",
+ "author": "Filip Vrubel",
+ "version": "1.0.0",
+ "description": "Plugin for creating and running jobs",
+ "icon": null,
+ "includes" : ["newAppForm.mjs", "analyzeDropdown.mjs"],
+ "permaLoad": true
+}
\ No newline at end of file
diff --git a/plugins/analyze/newAppForm.mjs b/plugins/analyze/newAppForm.mjs
new file mode 100644
index 00000000..a0b7cd4c
--- /dev/null
+++ b/plugins/analyze/newAppForm.mjs
@@ -0,0 +1,194 @@
+import van from "../../ui/vanjs.mjs";
+import { BaseComponent } from "../../ui/classes/baseComponent.mjs";
+import { FloatingWindow } from "../../ui/classes/components/floatingWindow.mjs";
+
+const { div, input, textarea, span, button, label, h3 } = van.tags;
+
+class NewAppForm extends BaseComponent {
+ constructor(options = {}) {
+ options = super(options).options;
+ this.id = options.id || 'new-app';
+ this.onSubmit = options.onSubmit || (() => {});
+ this.values = options.values || {};
+ this._el = null;
+ this._fields = {};
+ this._floatingWindow = null;
+ }
+
+ _row(labelText, fieldNode) {
+ return div({ class: 'mb-2' },
+ label({ class: 'block mb-1 text-xs font-medium' }, labelText),
+ fieldNode
+ );
+ }
+
+ create() {
+ if (this._el) return this._el;
+
+ // use explicit inline sizing so styles apply even if utility classes are missing
+ const smallInputStyle = 'height:30px;padding:4px 6px;font-size:12px;line-height:1.1;box-sizing:border-box;';
+ const smallTextareaBase = 'padding:6px 6px;font-size:12px;line-height:1.2;box-sizing:border-box;resize:vertical;';
+ const schemaEl = input({ type: 'text', id: this.id + '-schema', class: 'input w-full', style: smallInputStyle, value: this.values.schema || '' });
+ const nameEl = input({ type: 'text', id: this.id + '-name', class: 'input w-full', style: smallInputStyle, value: this.values.name || '' });
+ const nsEl = input({ type: 'text', id: this.id + '-namespace', class: 'input w-full', style: smallInputStyle, value: this.values.namespace || '' });
+ const descEl = textarea({ id: this.id + '-description', class: 'textarea w-full', style: smallTextareaBase + 'height:64px;', rows: 4 }, this.values.description || '');
+ const inputsEl = textarea({ id: this.id + '-inputs', class: 'textarea w-full', style: smallTextareaBase + 'height:48px;', rows: 3 }, this.values.inputs || '');
+ const outputsEl = textarea({ id: this.id + '-outputs', class: 'textarea w-full', style: smallTextareaBase + 'height:48px;', rows: 3 }, this.values.outputs || '');
+ const jobEl = input({ type: 'text', id: this.id + '-joburl', class: 'input w-full', style: smallInputStyle, value: this.values.jobUrl || '' });
+
+ const btnEdit = button({ class: 'btn btn-secondary btn-sm mr-2', type: 'button', onclick: (ev) => this._onEdit() }, 'Edit EAD');
+ const btnCreate = button({ class: 'btn btn-primary btn-sm', type: 'button', onclick: (ev) => this._onCreate() }, 'Create');
+
+ // remove inner title and close button to rely on the FloatingWindow header
+ // increase top padding so the FloatingWindow title has breathing room
+ const form = div({ class: 'p-4 bg-base-200 border border-base-300 rounded-md max-w-full relative', style: 'max-width:420px;width:100%;' },
+ this._row('Schema:', schemaEl),
+ this._row('Name:', nameEl),
+ this._row('Namespace:', nsEl),
+ this._row('Description:', descEl),
+ this._row('Inputs:', inputsEl),
+ this._row('Outputs:', outputsEl),
+ this._row('Job URL:', jobEl),
+ div({ class: 'mt-4 flex gap-2 justify-end' }, btnEdit, btnCreate)
+ );
+
+ this._fields = {
+ schema: schemaEl,
+ name: nameEl,
+ namespace: nsEl,
+ description: descEl,
+ inputs: inputsEl,
+ outputs: outputsEl,
+ jobUrl: jobEl
+ };
+
+ // make the form fill available height inside a FloatingWindow and layout vertically
+ try {
+ this._el = form;
+ // allow flex layout to let textareas expand/shrink with window
+ this._el.style.display = 'flex';
+ this._el.style.flexDirection = 'column';
+ this._el.style.height = '100%';
+
+ // ensure textareas grow and shrink with the container (min-height:0 prevents overflow)
+ const makeFlexTA = (el) => {
+ if (!el) return;
+ el.style.flex = '1 1 auto';
+ el.style.minHeight = '0';
+ // keep existing padding/box sizing
+ };
+ makeFlexTA(this._fields.description);
+ makeFlexTA(this._fields.inputs);
+ makeFlexTA(this._fields.outputs);
+
+ // push buttons to bottom
+ const btnContainer = this._el.querySelector('.mt-4');
+ if (btnContainer) btnContainer.style.marginTop = 'auto';
+ } catch (e) {
+ // non-fatal: fall back to previous behavior
+ this._el = form;
+ }
+
+ return this._el;
+ }
+
+ /**
+ * Close helper: prefer closing a floating window if we opened one;
+ * otherwise close the parent Dialog via Dialogs.closeWindow if present,
+ * fallback to removing the element from DOM (legacy behavior).
+ */
+ _close() {
+ try {
+ if (this._floatingWindow) {
+ try { this._floatingWindow.close(); } catch(_) {}
+ this._floatingWindow = null;
+ return;
+ }
+ // if inside a Dialog created via Dialogs.showCustom, find the dialog root
+ let root = this._el && this._el.closest ? this._el.closest('[data-dialog="true"]') : null;
+ if (root && root.id && window.USER_INTERFACE && USER_INTERFACE.Dialogs && typeof USER_INTERFACE.Dialogs.closeWindow === 'function') {
+ try { USER_INTERFACE.Dialogs.closeWindow(root.id); return; } catch (_) {}
+ }
+ // fallback: traditional removal of the attached wrapper
+ const el = this._el;
+ if (!el) return;
+ const wrapper = el.parentNode;
+ if (wrapper && wrapper.parentNode) {
+ wrapper.parentNode.removeChild(wrapper);
+ } else if (el.parentNode) {
+ el.parentNode.removeChild(el);
+ }
+ } catch (e) { console.error('NewAppForm _close error', e); }
+ }
+
+ _serialize() {
+ const f = this._fields;
+ const getVal = (el) => (el && el.value !== undefined) ? el.value : (el && el.textContent) || '';
+ return {
+ schema: getVal(f.schema),
+ name: getVal(f.name),
+ namespace: getVal(f.namespace),
+ description: getVal(f.description),
+ inputs: getVal(f.inputs),
+ outputs: getVal(f.outputs),
+ jobUrl: getVal(f.jobUrl),
+ };
+ }
+
+ _onEdit() {
+ }
+
+ _onCreate() {
+ const data = this._serialize();
+ try {
+ const r = this.onSubmit(data);
+ if (r !== false) {
+ try { if (this._floatingWindow) { this._floatingWindow.close(); this._floatingWindow = null; } } catch(_) {}
+ }
+ } catch (e) {
+ console.error('NewAppForm onSubmit error', e);
+ }
+ }
+
+ /**
+ * Show this form inside a FloatingWindow. Returns the FloatingWindow instance.
+ * Options may include width/height/title.
+ */
+ showFloating(opts = {}) {
+ try {
+ if (this._floatingWindow) return this._floatingWindow;
+ const id = this.id + '-window';
+ // compute centered start position when possible
+ const width = opts.width || 420;
+ // increase default height so the form fits comfortably; still allows scrolling
+ const height = opts.height || 520;
+ const startLeft = (typeof window !== 'undefined') ? Math.max(8, Math.round((window.innerWidth - width) / 2)) : (opts.startLeft || 64);
+ const startTop = (typeof window !== 'undefined') ? Math.max(8, Math.round((window.innerHeight - height) / 2)) : (opts.startTop || 64);
+ const w = new FloatingWindow({ id, title: opts.title || 'New App', width, height, startLeft, startTop, onClose: () => { this._floatingWindow = null; } }, );
+ // attach window to body so it is visible
+ w.attachTo(document.body);
+ // wrap the form in a scrollable card-body so FloatingWindow keeps expected layout
+ const wrapper = document.createElement('div');
+ wrapper.className = 'card-body p-3 gap-2 overflow-auto';
+ wrapper.style.height = '100%';
+ wrapper.appendChild(this.create());
+ // set the body to our wrapper node
+ w.setBody(wrapper);
+ this._floatingWindow = w;
+ return w;
+ } catch (e) { console.error('NewAppForm showFloating error', e); }
+ return null;
+ }
+
+ attachTo(parent) {
+ const target = (typeof parent === 'string') ? document.getElementById(parent) : parent;
+ if (!target) throw new Error('attachTo: parent not found');
+ target.appendChild(this.create());
+ }
+
+ getValues() {
+ return this._serialize();
+ }
+}
+
+export { NewAppForm };
diff --git a/ui/classes/baseComponent.mjs b/ui/classes/baseComponent.mjs
index 799ac90d..58d759ea 100644
--- a/ui/classes/baseComponent.mjs
+++ b/ui/classes/baseComponent.mjs
@@ -96,14 +96,19 @@ export class BaseComponent {
}
/**
- *
* @param {*} element - The element to attach the component to
+ * @return {BaseComponent} builder pattern
*/
attachTo(element) {
this.refreshClassState();
this.refreshPropertiesState();
- if (element instanceof BaseComponent) {
+ // Accept true BaseComponent instances and component-like objects
+ const isComponentLike = (el) => {
+ return !!el && (el instanceof BaseComponent || (typeof el === "object" && typeof el.create === "function" && typeof el.id === "string"));
+ };
+
+ if (isComponentLike(element)) {
const mount = document.getElementById(element.id);
if (mount === null) {
element._children.push(this);
@@ -111,22 +116,47 @@ export class BaseComponent {
mount.append(this.create());
}
} else {
- const mount = typeof element === "string"
+ // Resolve mount target from id/string or direct reference
+ let mount = typeof element === "string"
? document.getElementById(element)
: element;
+ // If we got a jQuery/Cash wrapper, unwrap to the first DOM node
+ // (supports libraries exposing .jquery flag or .get/.[0])
+ if (mount && (mount.jquery || typeof mount.get === "function" || Array.isArray(mount))) {
+ const candidate = typeof mount.get === "function" ? mount.get(0) : (Array.isArray(mount) ? mount[0] : mount[0]);
+ if (candidate) mount = candidate;
+ }
+
if (!mount) {
console.error(`Element ${element} not found`);
- van.add(element, this.create());
- } else {
+ try {
+ van.add(element, this.create());
+ } catch (_) { /* noop: element may be invalid for van.add */ }
+ } else if (typeof mount.append === "function") {
mount.append(this.create());
+ } else if (mount.nodeType || mount instanceof Node) {
+ // Fallback for very old environments where append may be missing
+ const created = this.create();
+ if (mount.appendChild) mount.appendChild(created);
+ else {
+ try { mount.innerHTML += created.outerHTML || String(created); } catch (_) {}
+ }
+ } else {
+ // Last resort: try jQuery-style append if available or log a clearer error
+ try { mount.append(this.create()); }
+ catch (e) {
+ console.error("Failed to attach component: unsupported mount target", mount);
+ console.error(e);
+ }
}
}
+ return this;
}
/**
- *
* @param {*} element - The element to prepend the component to
+ * @return {BaseComponent} builder pattern
*/
prependedTo(element) {
this.refreshClassState();
@@ -151,6 +181,7 @@ export class BaseComponent {
mount.prepend(this.create());
}
}
+ return this;
}
/**
@@ -267,6 +298,22 @@ export class BaseComponent {
this.classState.val = Object.values(this.classMap).join(" ");
}
+ /**
+ * Toggle the class of the component
+ * @param {string} key
+ * @param {string} value
+ * @param {boolean} on if true, set class
+ */
+ toggleClass(key, value, on=true) {
+ this.classMap[key] = on ? value : "";
+ this.classState.val = Object.values(this.classMap).join(" ");
+ }
+
+ /**
+ * Set attribute property to the element
+ * @param {string} key attribute name
+ * @param {string} value
+ */
setExtraProperty(key, value) {
this.propertiesMap[key] = value;
let stateMap = this.propertiesStateMap[key];
@@ -409,3 +456,18 @@ export class BaseComponent {
this.refreshPropertiesState();
}
}
+
+/**
+ * @typedef {BaseUIOptions} SelectableUIOptions
+ * @property {string} [itemID] - The selection ID
+ */
+export class BaseSelectableComponent extends BaseComponent {
+ constructor(options, ...args) {
+ options = super(options, ...args);
+ this.itemID = options.itemID || this.id;
+ }
+
+ setSelected(itemID) {
+ throw new Error("Component must override setSelected method");
+ }
+}
diff --git a/ui/classes/components/sidePanel.mjs b/ui/classes/components/sidePanel.mjs
new file mode 100644
index 00000000..5e06b1d3
--- /dev/null
+++ b/ui/classes/components/sidePanel.mjs
@@ -0,0 +1,155 @@
+import { BaseComponent } from "../baseComponent.mjs";
+
+/**
+ * SidePanel: small reusable fixed-position panel that plugins can show near an anchor.
+ * Simple API: constructor(options), attachToBody(), setBody(node|string), showAt({left,top}), hide(), remove().
+ */
+class SidePanel extends BaseComponent {
+ constructor(options = {}) {
+ options = super(options).options;
+ this.id = options.id || `side-panel-${Math.random().toString(36).slice(2,8)}`;
+ // width can be a number (px) or 'auto'
+ this.width = (options.width === undefined) ? 'auto' : options.width;
+ this.minWidth = options.minWidth || 120;
+ this.maxWidth = options.maxWidth || 420;
+ this.maxHeight = options.maxHeight || '70vh';
+ this._el = null;
+ this._hideTimer = null;
+ this.hoverDelay = options.hoverDelay || 250;
+ }
+
+ create() {
+ if (this._el) return this._el;
+ const el = document.createElement('div');
+ el.id = this.id;
+ el.className = ['dropdown-content','bg-base-200','text-base-content','rounded-box','shadow-xl','border','border-base-300'].join(' ');
+ el.style.position = 'fixed';
+ // let the panel size to its content by default, but constrain widths
+ if (typeof this.width === 'number') el.style.width = `${this.width}px`;
+ else el.style.width = 'auto';
+ el.style.minWidth = `${this.minWidth}px`;
+ el.style.maxWidth = (typeof this.maxWidth === 'number') ? `${this.maxWidth}px` : this.maxWidth;
+ el.style.maxHeight = this.maxHeight;
+ el.style.overflow = 'auto';
+ el.style.zIndex = '9999';
+ // ensure it doesn't capture pointer events unexpectedly
+ return (this._el = el);
+ }
+
+ attachToBody() {
+ const el = this.create();
+ if (!document.body.contains(el)) document.body.appendChild(el);
+ }
+
+ // convenience: build a simple vertical menu from an array of labels or objects
+ // items: array of strings or { label, value }
+ // onClick: function(item, index)
+ setMenu(items = [], onClick) {
+ const node = document.createElement('div');
+ node.className = 'p-2';
+ const ul = document.createElement('ul'); ul.className = 'menu bg-transparent p-0'; ul.setAttribute('role','menu');
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ const label = (typeof it === 'string') ? it : (it && it.label) || String(it);
+ const li = document.createElement('li'); li.setAttribute('role','none');
+ const a = document.createElement('a');
+ a.className = 'flex items-center gap-3 rounded-md px-3 py-2 hover:bg-base-300 focus:bg-base-300';
+ a.setAttribute('role','menuitem'); a.setAttribute('tabindex','-1');
+ a.textContent = label;
+ // capture index/value for the click handler
+ a.addEventListener('click', (ev) => {
+ try { ev.stopPropagation(); if (typeof onClick === 'function') onClick(it, i); } catch(_){}
+ });
+ li.appendChild(a); ul.appendChild(li);
+ }
+ node.appendChild(ul);
+ this.setBody(node);
+ }
+
+ cancelHide() { if (this._hideTimer) { clearTimeout(this._hideTimer); this._hideTimer = null; } }
+ scheduleHide(delay) { this.cancelHide(); this._hideTimer = setTimeout(()=>{ try{ this.hide(); } catch(_){}; this._hideTimer = null; }, (typeof delay === 'number') ? delay : this.hoverDelay); }
+
+ // Position the panel adjacent to an anchor element or rect and show it.
+ // anchor: Element or DOMRect-like { left, right, top }
+ // opts: { nudge: number, offsetY: number }
+ showNear(anchor, opts = {}) {
+ try {
+ const el = this.create();
+ this.attachToBody();
+ const rect = (anchor && anchor.getBoundingClientRect) ? anchor.getBoundingClientRect() : (anchor || { left: 0, right: 0, top: 0 });
+ const nudge = (opts.nudge === undefined) ? 1 : opts.nudge;
+ const offsetY = opts.offsetY || 0;
+ requestAnimationFrame(() => {
+ try {
+ const panelEl = document.getElementById(this.id);
+ const panelW = panelEl && panelEl.offsetWidth ? panelEl.offsetWidth : (typeof this.width === 'number' ? this.width : this.minWidth);
+ const panelH = panelEl && panelEl.offsetHeight ? panelEl.offsetHeight : 0;
+ let left = rect.right;
+ if (left + panelW > window.innerWidth - 8) {
+ left = Math.max(8, rect.left - panelW);
+ } else {
+ left = Math.max(8, left - nudge);
+ }
+ let top = Math.max(8, rect.top + offsetY);
+ // clamp vertically so the panel stays within viewport when possible
+ if (panelH && (top + panelH > window.innerHeight - 8)) {
+ top = Math.max(8, window.innerHeight - panelH - 8);
+ }
+ panelEl.style.left = `${left}px`;
+ panelEl.style.top = `${top}px`;
+ panelEl.style.display = '';
+ // ensure hover keeps it open
+ panelEl.addEventListener('mouseenter', () => { this.cancelHide(); });
+ panelEl.addEventListener('mouseleave', () => { this.scheduleHide(); });
+ } catch (e) { /* swallow layout issues */ }
+ });
+ } catch (e) { /* ignore */ }
+ }
+
+ setBody(content) {
+ const el = this.create();
+ el.innerHTML = '';
+ if (typeof content === 'string') {
+ el.innerHTML = content;
+ } else if (content instanceof Node) {
+ el.appendChild(content);
+ } else if (content && typeof content.create === 'function') {
+ el.appendChild(content.create());
+ }
+ // allow layout to settle then ensure width fits content within min/max
+ try {
+ requestAnimationFrame(() => {
+ try {
+ // if width is 'auto' let the browser size it naturally; enforce min/max via CSS
+ if (this.width === 'auto') {
+ el.style.width = 'auto';
+ // no further action; CSS min/max will constrain
+ } else if (typeof this.width === 'number') {
+ el.style.width = `${this.width}px`;
+ }
+ } catch (e) {}
+ });
+ } catch (e) {}
+ }
+
+ showAt({ left = 0, top = 0 } = {}) {
+ const el = this.create();
+ this.attachToBody();
+ el.style.left = `${left}px`;
+ el.style.top = `${top}px`;
+ el.style.display = '';
+ }
+
+ hide() {
+ const el = this._el; if (!el) return;
+ try { el.remove(); } catch(_) { el.style.display = 'none'; }
+ }
+
+ remove() {
+ if (!this._el) return;
+ try { this._el.parentNode && this._el.parentNode.removeChild(this._el); } catch(_) {}
+ this._el = null;
+ }
+}
+
+export { SidePanel };