Files

390 lines
12 KiB
JavaScript
Raw Permalink Normal View History

2025-09-26 14:28:04 -04:00
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 = '<span aria-hidden="true">i</span>';
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;
}