function generateToolCard(tool, options = {}) { const mount = options.mountSelector ? (document.querySelector(options.mountSelector)) : document.body; if (!mount) throw new Error('mount element not found'); // overlay const overlay = document.createElement('div'); overlay.className = 'tc-overlay'; overlay.setAttribute('data-testid', 'tool-card-overlay'); // dialog shell const dialog = document.createElement('div'); dialog.className = 'tc-dialog'; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); // per apg modal dialog pattern // apg & mdn recommend explicit labelling/description for dialogs const titleId = `tc-title-${Math.random().toString(36).slice(2)}`, descId = `tc-desc-${Math.random().toString(36).slice(2)}`; dialog.setAttribute('aria-labelledby', titleId); dialog.setAttribute('aria-describedby', descId); // header const header = document.createElement('header'); header.className = 'tc-header'; const titleWrap = document.createElement('div'); titleWrap.className = 'tc-titlewrap'; // // avatar (from user.profile_image_url if present) // const avatar = document.createElement('img'); // avatar.className = 'tc-avatar'; // const profileUrl = tool?.user?.profile_image_url; // if (typeof profileUrl === 'string' && profileUrl.length > 0) { // avatar.src = profileUrl; // avatar.alt = ''; // } else { // avatar.src = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; // avatar.alt = ''; // avatar.classList.add('tc-avatar--placeholder'); // } // title + owner const h1 = document.createElement('h1'); h1.id = titleId; h1.className = 'tc-title'; h1.textContent = tool?.name ?? tool?.id ?? 'tool'; // const owner = document.createElement('div'); // owner.className = 'tc-subtitle'; // owner.textContent = tool?.user?.name ? `by ${tool.user.name}` : ''; // titleWrap.appendChild(avatar); titleWrap.appendChild(h1); // if (owner.textContent) titleWrap.appendChild(owner); // close button const closeBtn = document.createElement('button'); closeBtn.className = 'tc-close'; closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'close'); closeBtn.textContent = '×'; header.appendChild(titleWrap); header.appendChild(closeBtn); // body const body = document.createElement('div'); body.className = 'tc-body'; // description (primary) const desc = document.createElement('p'); desc.id = descId; desc.className = 'tc-desc'; desc.textContent = tool?.meta?.description ?? tool?.meta?.manifest?.description ?? 'no description provided.'; // badges (primary) const badges = document.createElement('div'); badges.className = 'tc-badges'; const addBadge = (label, value) => { if (value === undefined || value === null || value === '') return; const b = document.createElement('span'); b.className = 'tc-badge'; b.textContent = `${label}: ${value}`; badges.appendChild(b); }; addBadge('id', tool?.id); addBadge('user', tool?.user?.name ?? tool?.user_id); addBadge('version', tool?.meta?.manifest?.version); addBadge('license', tool?.meta?.manifest?.license ?? tool?.meta?.manifest?.licence); addBadge('updated', formatEpoch(tool?.updated_at)); addBadge('created', formatEpoch(tool?.created_at)); // manifest summary (primary) const manifestWrap = document.createElement('div'); manifestWrap.className = 'tc-section'; const manifestTitle = document.createElement('h2'); manifestTitle.className = 'tc-section-title'; manifestTitle.textContent = 'manifest'; const manifestList = document.createElement('ul'); manifestList.className = 'tc-manifest'; /** * @param {string} k * @param {string} v */ const kv = (k, v) => { if (v === undefined || v === null || v === '') return; const li = document.createElement('li'); li.className = 'tc-manifest-item'; const label = document.createElement('span'); label.className = 'tc-manifest-label'; label.textContent = k; li.appendChild(label); const valueEl = document.createElement('span'); valueEl.className = 'tc-manifest-value'; valueEl.textContent = typeof v === 'string' ? v : JSON.stringify(v); try { // add https to all of them const u = 'https://' + v.replace(/https?:\/\//, ''); new URL(u); if (!(/https:\/\/.*\..*/.test(u))) throw ""; const uEl = document.createElement('a'); uEl.href = u; uEl.target = '_blank'; uEl.appendChild(valueEl); li.appendChild(uEl); } catch { li.appendChild(valueEl); } manifestList.appendChild(li); }; const mani = tool?.meta?.manifest ?? {}; kv('title', mani.title); kv('author', mani.author); kv('author url', mani.author_url ?? mani.author_urls); kv('email', mani.email); kv('github', mani.github); kv('requirements', mani.requirements); kv('required webui', mani.required_open_webui_version); kv('funding url', mani.funding_url); kv('date', mani.date); manifestWrap.appendChild(manifestTitle); manifestWrap.appendChild(manifestList); // advanced details (disclosure) const adv = document.createElement('details'); adv.className = 'tc-disclosure'; adv.setAttribute('data-testid', 'tool-card-advanced'); const advSum = document.createElement('summary'); advSum.className = 'tc-disclosure-summary'; advSum.textContent = 'advanced details'; adv.appendChild(advSum); const advBody = document.createElement('div'); advBody.className = 'tc-disclosure-body'; // details table const table = document.createElement('table'); table.className = 'tc-table'; const tbody = document.createElement('tbody'); const rows = [ ['tool id', tool?.id], ['user id', tool?.user_id], ['user role', tool?.user?.role], ['email', tool?.user?.email], ['access control', safeText(Object.keys(tool?.access_control ?? {}).length ? JSON.stringify(tool.access_control) : '—')], ['updated at', formatEpoch(tool?.updated_at)], ['created at', formatEpoch(tool?.created_at)] ]; rows.forEach(([k, v]) => { if (v === undefined || v === null) return; const tr = document.createElement('tr'); const th = document.createElement('th'); th.textContent = k; const td = document.createElement('td'); td.textContent = String(v); tr.appendChild(th); tr.appendChild(td); tbody.appendChild(tr); }); table.appendChild(tbody); // raw json (nested disclosure) const rawWrap = document.createElement('details'); rawWrap.className = 'tc-disclosure tc-disclosure--nested'; const rawSum = document.createElement('summary'); rawSum.className = 'tc-disclosure-summary'; rawSum.textContent = 'raw tool json'; const pre = document.createElement('pre'); pre.className = 'tc-pre'; pre.textContent = prettyJson(tool); rawWrap.appendChild(rawSum); rawWrap.appendChild(pre); // assemble advBody.appendChild(table); advBody.appendChild(rawWrap); adv.appendChild(advBody); body.appendChild(desc); body.appendChild(badges); body.appendChild(manifestWrap); body.appendChild(adv); dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); mount.appendChild(overlay); // focus management per apg recommendations: trap focus inside the dialog, close on esc const opener = options.trigger instanceof HTMLElement ? options.trigger : /** @type {HTMLElement|null} */(document.activeElement); const focusableSelectors = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])' ].join(','); const findFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors))), focusFirst = () => { const items = findFocusable(); const target = items[0] || closeBtn; target.focus(); }; const onKeydown = (e) => { if (e.key === 'Tab') { const items = findFocusable(); if (items.length === 0) { e.preventDefault(); closeBtn.focus(); return; } const first = items[0], last = items[items.length - 1], active = /** @type {HTMLElement} */(document.activeElement); if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } else if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } } if (e.key === 'Escape') { e.preventDefault(); close(); } }; const onOverlayClick = (e) => { if (e.target === overlay) close(); }; overlay.addEventListener('click', onOverlayClick); document.addEventListener('keydown', onKeydown); closeBtn.addEventListener('click', () => close()); // prevent background scroll while open const prevOverflow = document.documentElement.style.overflow; document.documentElement.style.overflow = 'hidden'; // open setTimeout(focusFirst, 0); function close() { overlay.removeEventListener('click', onOverlayClick); document.removeEventListener('keydown', onKeydown); document.documentElement.style.overflow = prevOverflow; overlay.remove(); if (opener && typeof opener.focus === 'function') opener.focus(); } return { close, root: overlay }; } // helpers function renderToolList(tools) { const container = document.querySelector('#tools-select'), hiddenInput = document.querySelector('#tools-select-input'); if (!container) return console.warn('failed to find tools selection container'); container.innerHTML = ''; container.setAttribute('data-has-tools', String(Boolean(tools?.length))); const initialSelectionRaw = hiddenInput?.value ? hiddenInput.value.split(',') : [], initialSelection = new Set(initialSelectionRaw.map(v => v.trim()).filter(Boolean)); if (!Array.isArray(tools) || tools.length === 0) { const empty = document.createElement('p'); empty.className = 'tool-pill-empty muted'; empty.textContent = 'no tools available'; container.appendChild(empty); if (hiddenInput) { hiddenInput.value = ''; hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); } return; } const selection = new Set(initialSelection), validIds = new Set(); const syncHidden = () => { if (!hiddenInput) return; const ordered = Array.from(selection).filter(id => validIds.has(id)); hiddenInput.value = ordered.join(','); hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); }; const toggleSelection = (pill, toggleBtn, id) => { const isSelected = selection.has(id); if (isSelected) selection.delete(id); else selection.add(id); const nowSelected = selection.has(id); pill.classList.toggle('tool-pill--selected', nowSelected); pill.setAttribute('aria-selected', String(nowSelected)); toggleBtn.setAttribute('aria-pressed', String(nowSelected)); syncHidden(); }; tools.forEach(tool => { const id = String(tool?.id ?? ''); if (!id) return; validIds.add(id); const label = makeToolLabel(tool, tools); const pill = document.createElement('div'); pill.className = 'tool-pill'; pill.dataset.toolId = id; pill.setAttribute('role', 'option'); pill.setAttribute('tabindex', '-1'); const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.className = 'tool-pill-toggle'; toggleBtn.textContent = label; toggleBtn.setAttribute('aria-pressed', String(selection.has(id))); toggleBtn.addEventListener('click', () => toggleSelection(pill, toggleBtn, id)); const infoBtn = document.createElement('button'); infoBtn.type = 'button'; infoBtn.className = 'tool-pill-info'; infoBtn.setAttribute('aria-label', `Show info for ${label}`); infoBtn.innerHTML = ''; infoBtn.addEventListener('click', (event) => { event.stopPropagation(); generateToolCard(tool, { trigger: infoBtn }); }); const isSelected = selection.has(id); pill.classList.toggle('tool-pill--selected', isSelected); pill.setAttribute('aria-selected', String(isSelected)); pill.appendChild(toggleBtn); pill.appendChild(infoBtn); container.appendChild(pill); }); // drop any selections for tools that no longer exist Array.from(selection).forEach(id => { if (!validIds.has(id)) selection.delete(id); }); syncHidden(); } const makeToolLabel = (t, tools) => { const base = t?.name ?? t?.id ?? 'tool'; const duplicates = tools.filter(x => (x?.name ?? x?.id) === (t?.name ?? t?.id)); if (duplicates.length > 1) { const hint = t?.meta?.manifest?.version ?? t?.user?.name ?? t?.id?.slice?.(0, 8); return `${base} • ${hint}`; } return base; }