390 lines
12 KiB
JavaScript
390 lines
12 KiB
JavaScript
|
|
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;
|
|||
|
|
}
|