added scheduler
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
:root {
|
||||
--fc-surface: #0f2219;
|
||||
--fc-surface-2: #103220;
|
||||
--fc-text: #e8f9f0;
|
||||
--fc-muted: #7dc9a5;
|
||||
--fc-accent: #2dd282;
|
||||
--fc-accent-soft: rgba(45, 210, 130, 0.18);
|
||||
--fc-border: rgba(125, 201, 165, 0.45);
|
||||
--fc-border-strong: rgba(45, 210, 130, 0.65);
|
||||
--fc-overlay: rgba(4, 18, 12, 0.72);
|
||||
}
|
||||
|
||||
.feature-section {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feature-section-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.feature-section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.feature-pill-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.feature-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--fc-border);
|
||||
background: var(--fc-accent-soft);
|
||||
color: inherit;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.feature-pill--selected {
|
||||
background: color-mix(in srgb, var(--fc-accent), transparent 68%);
|
||||
border-color: var(--fc-border-strong);
|
||||
box-shadow: 0 2px 6px rgba(21, 94, 50, 0.22);
|
||||
}
|
||||
|
||||
.feature-pill-toggle {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature-pill-toggle:focus-visible,
|
||||
.feature-pill-info:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--fc-accent), white 25%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.feature-pill-info {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fc-muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.feature-pill-info:hover {
|
||||
color: var(--fc-text);
|
||||
background: rgba(45, 210, 130, 0.16);
|
||||
}
|
||||
|
||||
.feature-pill-info span {
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.fc-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(12px, 2vw, 24px);
|
||||
background: var(--fc-overlay);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.fc-dialog {
|
||||
width: min(520px, 100%);
|
||||
max-height: 90dvh;
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, var(--fc-surface), var(--fc-surface-2));
|
||||
color: var(--fc-text);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(125, 201, 165, 0.35);
|
||||
}
|
||||
|
||||
.fc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(125, 201, 165, 0.25);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
backdrop-filter: blur(6px);
|
||||
background:
|
||||
linear-gradient(to bottom, color-mix(in srgb, var(--fc-surface), transparent 30%), color-mix(in srgb, var(--fc-surface-2), transparent 35%)),
|
||||
conic-gradient(from 0.35turn at 12% -30%, color-mix(in srgb, var(--fc-accent), transparent 80%), transparent 40%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fc-titlewrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fc-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fc-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #06361d;
|
||||
background: color-mix(in srgb, var(--fc-accent), white 38%);
|
||||
border: 1px solid color-mix(in srgb, var(--fc-accent), black 10%);
|
||||
}
|
||||
|
||||
.fc-close {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--fc-muted);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fc-close:hover {
|
||||
color: var(--fc-text);
|
||||
border-color: rgba(125, 201, 165, 0.4);
|
||||
background: rgba(45, 210, 130, 0.16);
|
||||
}
|
||||
|
||||
.fc-body {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.fc-desc {
|
||||
margin: 0;
|
||||
color: var(--fc-text);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.fc-details {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 6px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fc-details dt {
|
||||
font-weight: 600;
|
||||
color: var(--fc-muted);
|
||||
}
|
||||
|
||||
.fc-details dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
const FEATURE_DEFAULTS = {
|
||||
image_generation: false,
|
||||
code_interpreter: false,
|
||||
web_search: false,
|
||||
memory: true,
|
||||
};
|
||||
|
||||
const FEATURE_METADATA = {
|
||||
image_generation: {
|
||||
label: 'Image Generation',
|
||||
description: 'Request image outputs from supported models. When enabled the assistant may produce images alongside text responses.',
|
||||
},
|
||||
code_interpreter: {
|
||||
label: 'Code Interpreter',
|
||||
description: 'Grant access to the sandboxed runtime for running Python snippets and data transformations during the task.',
|
||||
},
|
||||
web_search: {
|
||||
label: 'Web Search',
|
||||
description: 'Allow the assistant to perform outbound web searches to enrich the response with current information.',
|
||||
},
|
||||
memory: {
|
||||
label: 'Memory',
|
||||
description: 'Persist relevant conversation context for future automations so runs can recall prior outcomes.',
|
||||
},
|
||||
};
|
||||
|
||||
const FEATURE_SECTION_TAG = 'Feature';
|
||||
|
||||
function renderFeatureList(featureState) {
|
||||
const container = document.querySelector('#features-select');
|
||||
const hiddenInput = document.querySelector('#features-select-input');
|
||||
if (!container || !hiddenInput) return;
|
||||
|
||||
const baseState = normalizeFeatureState(featureState),
|
||||
featureKeys = Object.keys(baseState);
|
||||
|
||||
container.innerHTML = '';
|
||||
container.setAttribute('data-has-features', String(featureKeys.length > 0));
|
||||
|
||||
const selection = { ...baseState },
|
||||
syncHidden = () => {
|
||||
hiddenInput.value = JSON.stringify(selection);
|
||||
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
|
||||
featureKeys.forEach((id) => {
|
||||
const meta = getFeatureMeta(id);
|
||||
const pill = document.createElement('div');
|
||||
pill.className = 'feature-pill';
|
||||
pill.dataset.featureId = id;
|
||||
pill.setAttribute('role', 'option');
|
||||
pill.setAttribute('tabindex', '-1');
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'feature-pill-toggle';
|
||||
toggleBtn.textContent = meta.label;
|
||||
toggleBtn.setAttribute('aria-pressed', String(Boolean(selection[id])));
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
selection[id] = !selection[id];
|
||||
const isSelected = Boolean(selection[id]);
|
||||
pill.classList.toggle('feature-pill--selected', isSelected);
|
||||
pill.setAttribute('aria-selected', String(isSelected));
|
||||
toggleBtn.setAttribute('aria-pressed', String(isSelected));
|
||||
syncHidden();
|
||||
});
|
||||
|
||||
const infoBtn = document.createElement('button');
|
||||
infoBtn.type = 'button';
|
||||
infoBtn.className = 'feature-pill-info';
|
||||
infoBtn.setAttribute('aria-label', `Show info for ${meta.label}`);
|
||||
infoBtn.innerHTML = '<span aria-hidden="true">i</span>';
|
||||
infoBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
generateFeatureCard({ id, ...meta, selected: Boolean(selection[id]) }, { trigger: infoBtn });
|
||||
});
|
||||
|
||||
const isSelected = Boolean(selection[id]);
|
||||
pill.classList.toggle('feature-pill--selected', isSelected);
|
||||
pill.setAttribute('aria-selected', String(isSelected));
|
||||
|
||||
pill.appendChild(toggleBtn);
|
||||
pill.appendChild(infoBtn);
|
||||
container.appendChild(pill);
|
||||
});
|
||||
|
||||
syncHidden();
|
||||
}
|
||||
|
||||
function normalizeFeatureState(featureState) {
|
||||
const normalized = { ...FEATURE_DEFAULTS };
|
||||
if (featureState && typeof featureState === 'object' && !Array.isArray(featureState)) {
|
||||
for (const [key, value] of Object.entries(featureState)) {
|
||||
if (Object.prototype.hasOwnProperty.call(FEATURE_DEFAULTS, key) || Object.prototype.hasOwnProperty.call(FEATURE_METADATA, key)) {
|
||||
normalized[key] = Boolean(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getFeatureMeta(id) {
|
||||
if (Object.prototype.hasOwnProperty.call(FEATURE_METADATA, id)) {
|
||||
return FEATURE_METADATA[id];
|
||||
}
|
||||
const label = id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return {
|
||||
label,
|
||||
description: 'Toggle this capability for the scheduled run.',
|
||||
};
|
||||
}
|
||||
|
||||
function generateFeatureCard(feature, options = {}) {
|
||||
const mount = options.mountSelector ? document.querySelector(options.mountSelector) : document.body;
|
||||
if (!mount) throw new Error('mount element not found');
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fc-overlay';
|
||||
overlay.setAttribute('data-testid', 'feature-card-overlay');
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'fc-dialog';
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
|
||||
const titleId = `fc-title-${Math.random().toString(36).slice(2)}`;
|
||||
const descId = `fc-desc-${Math.random().toString(36).slice(2)}`;
|
||||
dialog.setAttribute('aria-labelledby', titleId);
|
||||
dialog.setAttribute('aria-describedby', descId);
|
||||
|
||||
const header = document.createElement('header');
|
||||
header.className = 'fc-header';
|
||||
|
||||
const titleWrap = document.createElement('div');
|
||||
titleWrap.className = 'fc-titlewrap';
|
||||
|
||||
const h1 = document.createElement('h1');
|
||||
h1.id = titleId;
|
||||
h1.className = 'fc-title';
|
||||
h1.textContent = feature.label;
|
||||
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'fc-tag';
|
||||
tag.textContent = FEATURE_SECTION_TAG;
|
||||
tag.setAttribute('data-testid', 'feature-tag');
|
||||
|
||||
titleWrap.appendChild(h1);
|
||||
titleWrap.appendChild(tag);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'fc-close';
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.setAttribute('aria-label', 'close');
|
||||
closeBtn.textContent = '×';
|
||||
|
||||
header.appendChild(titleWrap);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'fc-body';
|
||||
|
||||
const desc = document.createElement('p');
|
||||
desc.id = descId;
|
||||
desc.className = 'fc-desc';
|
||||
desc.textContent = feature.description;
|
||||
|
||||
const details = document.createElement('dl');
|
||||
details.className = 'fc-details';
|
||||
|
||||
const pairs = [
|
||||
['Identifier', feature.id],
|
||||
['Current state', feature.selected ? 'Enabled' : 'Disabled'],
|
||||
];
|
||||
|
||||
pairs.forEach(([label, value]) => {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = label;
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = value;
|
||||
details.appendChild(dt);
|
||||
details.appendChild(dd);
|
||||
});
|
||||
|
||||
body.appendChild(desc);
|
||||
body.appendChild(details);
|
||||
|
||||
dialog.appendChild(header);
|
||||
dialog.appendChild(body);
|
||||
overlay.appendChild(dialog);
|
||||
mount.appendChild(overlay);
|
||||
|
||||
const opener = options.trigger instanceof HTMLElement ? options.trigger : document.activeElement;
|
||||
|
||||
const focusableSelectors = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(',');
|
||||
|
||||
const getFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors)));
|
||||
|
||||
const focusFirst = () => {
|
||||
const items = getFocusable();
|
||||
(items[0] || closeBtn).focus();
|
||||
};
|
||||
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Tab') {
|
||||
const items = getFocusable();
|
||||
if (items.length === 0) {
|
||||
event.preventDefault();
|
||||
closeBtn.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = /** @type {HTMLElement} */(document.activeElement);
|
||||
if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
} else if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) close();
|
||||
};
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
closeBtn.addEventListener('click', () => close());
|
||||
|
||||
const prevOverflow = document.documentElement.style.overflow;
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
|
||||
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 };
|
||||
}
|
||||
+183
-97
@@ -5,7 +5,13 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Schedules • Task & Workflow Manager</title>
|
||||
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="modelList.css" />
|
||||
<link rel="stylesheet" href="toolList.css" />
|
||||
<link rel="stylesheet" href="featureList.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/10.4.1/jsoneditor.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/10.4.1/jsoneditor.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -30,6 +36,64 @@
|
||||
<main class="content-grid">
|
||||
<!-- left column: auth + run now -->
|
||||
<aside class="stack col-left">
|
||||
<!-- user summary card (shows when logged in) -->
|
||||
<section class="card user-card" id="userCard" aria-hidden="true">
|
||||
<header class="card-header">
|
||||
<h2 style="margin-top: 0px;">Account</h2>
|
||||
</header>
|
||||
|
||||
<div class="user-compact">
|
||||
<img id="userAvatar" class="avatar" src="" alt="" aria-hidden="true" />
|
||||
<div class="user-info">
|
||||
<div id="userName" class="user-name muted">not logged in</div>
|
||||
<div id="userEmail" class="muted user-email"></div>
|
||||
<div id="userRole" class="muted user-role"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- permissions summary -->
|
||||
<div id="userPermissions" class="permissions" aria-live="polite" hidden>
|
||||
<h3 class="small">Permissions</h3>
|
||||
<ul class="perms-list">
|
||||
<li>
|
||||
<span class="perm-key">Workspace</span>
|
||||
<ul class="nested-perms">
|
||||
<li>
|
||||
<span class="perm-val" data-perm="workspace.tools"></span>
|
||||
<span class="perm-key">Tools</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<span class="perm-key">Chat</span>
|
||||
<ul class="nested-perms">
|
||||
<li>
|
||||
<span class="perm-val" data-perm="chat.file_upload"></span>
|
||||
<span class="perm-key">File Upload</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<span class="perm-key">Features</span>
|
||||
<ul class="nested-perms">
|
||||
<li>
|
||||
<span class="perm-val" data-perm="features.web_search"></span>
|
||||
<span class="perm-key">Web Search</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="perm-val" data-perm="features.code_interpreter"></span>
|
||||
<span class="perm-key">Code Interpreter</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="userLogoutBtn" aria-variant="danger">Logout</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- login card (ids preserved) -->
|
||||
<section class="card" id="auth">
|
||||
<header class="card-header">
|
||||
@@ -38,65 +102,28 @@
|
||||
class="inline">x-user-id</code> header on api requests.</p>
|
||||
</header>
|
||||
|
||||
<form id="loginForm" class="form-stack">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="userId">user id (uuid)</label>
|
||||
<input id="userId" name="userId" type="text" required placeholder="e.g. 5a8d1d7e-..." />
|
||||
</div>
|
||||
<div>
|
||||
<label for="displayName">display name (optional)</label>
|
||||
<input id="displayName" name="displayName" type="text" placeholder="your name" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Login panel -->
|
||||
<section class="auth-card">
|
||||
<h2>Login</h2>
|
||||
<p id="authStatus" role="status">not logged in</p>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">save & set header</button>
|
||||
<button type="button" id="logoutBtn" aria-variant="ghost">logout</button>
|
||||
</div>
|
||||
<p id="authStatus" class="muted" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
<form id="loginForm" autocomplete="off">
|
||||
<label>
|
||||
Username
|
||||
<input id="username" name="username" required />
|
||||
</label>
|
||||
|
||||
<!-- run now -->
|
||||
<section class="card">
|
||||
<header class="card-header">
|
||||
<h2>run now</h2>
|
||||
<p class="muted">trigger a workflow ad‑hoc with parameters</p>
|
||||
</header>
|
||||
<label>
|
||||
Password
|
||||
<input id="password" name="password" type="password" required />
|
||||
</label>
|
||||
|
||||
<form id="runNowForm" class="form-stack">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="rnName">name (label only)</label>
|
||||
<input id="rnName" type="text" placeholder="ad-hoc-run" />
|
||||
<div class="row actions">
|
||||
<button type="submit">Login</button>
|
||||
<button type="button" id="logoutBtn">Logout</button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rnTemplateName">workflow template</label>
|
||||
<input id="rnTemplateName" type="text" placeholder="report-template" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="rnEntrypoint">entrypoint (optional)</label>
|
||||
<input id="rnEntrypoint" type="text" placeholder="main" />
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input id="rnClusterScope" type="checkbox" /> template is cluster-scoped
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="rnParams">parameters (json object)</label>
|
||||
<textarea id="rnParams" placeholder='{"report_kind":"summary"}'></textarea>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">run now</button>
|
||||
</div>
|
||||
<p id="runNowStatus" class="muted" aria-live="polite"></p>
|
||||
</form>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -118,9 +145,9 @@
|
||||
<tr>
|
||||
<th scope="col">name</th>
|
||||
<th scope="col">schedules</th>
|
||||
<th scope="col">tz</th>
|
||||
<th scope="col">start</th>
|
||||
<th scope="col">template</th>
|
||||
<th scope="col">entrypoint</th>
|
||||
<th scope="col">prompt</th>
|
||||
<th scope="col">one-shot</th>
|
||||
<th scope="col">actions</th>
|
||||
</tr>
|
||||
@@ -142,71 +169,130 @@
|
||||
<label for="name">name</label>
|
||||
<input id="name" name="name" type="text" required placeholder="daily-report" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tz">timezone</label>
|
||||
<input id="tz" name="tz" type="text" value="America/New_York" />
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="templateName">workflow template</label>
|
||||
<input id="templateName" name="templateName" type="text" required
|
||||
placeholder="report-template" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="iso">run at (iso datetime, or leave empty if using cron)</label>
|
||||
<input id="iso" name="iso" type="datetime-local" />
|
||||
<label for="startAt">start at (optional)</label>
|
||||
<input id="startAt" name="startAt" type="datetime-local" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="cron">cron (min hour day month *)</label>
|
||||
<label for="cron">cron (min hour day month day-of-week) *</label>
|
||||
<input id="cron" name="cron" type="text" placeholder="30 9 * * *" />
|
||||
<div id="cronError"></div>
|
||||
</div>
|
||||
|
||||
<div style="justify-content: center; height: 100%;">
|
||||
<div class="warning-box">
|
||||
<!-- exclamation icon -->
|
||||
<svg class="warning-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm1-13h-2v6h2V7zm0 8h-2v2h2v-2z" />
|
||||
</svg>
|
||||
<span>Cron is in EST (America/New_York)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="params">parameters (json object)</label>
|
||||
<div id="params" name="params" placeholder=''></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="label">Run Settings</div>
|
||||
<ul class="row">
|
||||
<li class="checkbox">
|
||||
<label>
|
||||
<input id="clusterScope" type="checkbox" /> template is cluster-scoped
|
||||
</label>
|
||||
</li>
|
||||
<li class="checkbox">
|
||||
<label>
|
||||
<input id="oneShot" type="checkbox" /> stop after first success (one-shot)
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<details class="templates">
|
||||
<summary class="muted">available workflow templates</summary>
|
||||
<ul id="templatesUl" class="muted"></ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="templateName">workflow template</label>
|
||||
<input id="templateName" name="templateName" type="text" required
|
||||
placeholder="report-template" />
|
||||
<label for="model-select">Model</label>
|
||||
<select id="model-select" name="model">
|
||||
<option value="">choose a model</option>
|
||||
</select>
|
||||
<button id="selected-model-card" type="button" class="selected-model-card" hidden></button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="entrypoint">entrypoint (optional)</label>
|
||||
<input id="entrypoint" name="entrypoint" type="text" placeholder="main" />
|
||||
<label id="tools-select-label">Tools</label>
|
||||
<div id="tools-select" class="tool-pill-group" role="listbox"
|
||||
aria-labelledby="tools-select-label" aria-multiselectable="true"></div>
|
||||
<input type="hidden" id="tools-select-input" name="tools" value="" />
|
||||
|
||||
<section class="feature-section" aria-labelledby="features-select-label">
|
||||
<header class="feature-section-header">
|
||||
<h3 id="features-select-label">Features</h3>
|
||||
<p class="muted">Toggle optional capabilities for the run.</p>
|
||||
</header>
|
||||
<div id="features-select" class="feature-pill-group" role="listbox"
|
||||
aria-labelledby="features-select-label" aria-multiselectable="true"></div>
|
||||
<input type="hidden" id="features-select-input" name="features" value="{}" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input id="clusterScope" type="checkbox" /> template is cluster-scoped
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input id="oneShot" type="checkbox" /> stop after first success (one-shot)
|
||||
</label>
|
||||
<section class="form-subsection">
|
||||
<h3>Prompt</h3>
|
||||
<p class="muted" style="margin-top: 0;">Supports Markdown formatting.</p>
|
||||
<textarea id="prompt" name="prompt" placeholder="Write your prompt in Markdown..."
|
||||
rows="10"></textarea>
|
||||
</section>
|
||||
|
||||
<section class="form-subsection">
|
||||
<h3>Attachments</h3>
|
||||
<p class="muted" style="margin-top: 0;">Optional files are uploaded with the schedule and shared with the run.</p>
|
||||
<label class="file-input">
|
||||
<span class="file-input-label">Select file(s)</span>
|
||||
<input type="file" id="scheduleFiles" name="scheduleFiles" multiple />
|
||||
</label>
|
||||
<ul id="scheduleFilesList" class="file-list"></ul>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<hr style="width: 100%;">
|
||||
|
||||
<div class="actions between wrap">
|
||||
<div class="actions">
|
||||
<button type="submit">upsert schedule</button>
|
||||
<button type="button" id="loadTemplatesBtn" aria-variant="ghost">load workflow
|
||||
templates</button>
|
||||
</div>
|
||||
<span id="createStatus" class="muted" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="params">parameters (json object)</label>
|
||||
<textarea id="params" name="params" placeholder='{"report_kind":"summary"}'></textarea>
|
||||
|
||||
<div class="actions between wrap">
|
||||
<div class="actions">
|
||||
<button type="submit">upsert schedule</button>
|
||||
<button type="button" id="loadTemplatesBtn" aria-variant="ghost">load workflow
|
||||
templates</button>
|
||||
</div>
|
||||
<span id="createStatus" class="muted" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<details class="templates">
|
||||
<summary class="muted">available workflow templates</summary>
|
||||
<ul id="templatesUl" class="muted"></ul>
|
||||
</details>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- scripts -->
|
||||
<script type="module" src="script.js"></script>
|
||||
<script src="modelList.js"></script>
|
||||
<script src="toolList.js"></script>
|
||||
<script src="featureList.js"></script>
|
||||
<script src="script.js" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
.mc-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(12px, 2vw, 24px);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.mc-dialog {
|
||||
width: min(880px, 100%);
|
||||
max-height: 90dvh;
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: #0b0b0c;
|
||||
color: #e8e8ea;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
backdrop-filter: blur(6px);
|
||||
background: linear-gradient(to bottom, rgba(11, 11, 12, 0.9), rgba(11, 11, 12, 0.7));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mc-titlewrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mc-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: #1a1a1d;
|
||||
}
|
||||
|
||||
.mc-avatar--placeholder {
|
||||
background: linear-gradient(135deg, #2a2a2e, #1e1e22);
|
||||
}
|
||||
|
||||
.mc-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mc-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: #b8b8bf;
|
||||
}
|
||||
|
||||
.mc-close {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #bfbfc7;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mc-close:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mc-body {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mc-desc {
|
||||
margin: 0;
|
||||
color: #d8d8de;
|
||||
}
|
||||
|
||||
.mc-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mc-badge {
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #e8e8ea;
|
||||
}
|
||||
|
||||
.mc-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mc-section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #f0f0f3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mc-capabilities {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px 14px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mc-cap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mc-cap-label {
|
||||
color: #d8d8de;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.mc-cap-state {
|
||||
font-size: 0.82rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mc-cap-state.is-yes {
|
||||
background: rgba(16, 185, 129, 0.22);
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
.mc-cap-state.is-no {
|
||||
background: rgba(239, 68, 68, 0.22);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.mc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mc-table th,
|
||||
.mc-table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.mc-table th {
|
||||
width: 30%;
|
||||
color: #bfc0c7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mc-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mc-links a {
|
||||
color: #a6c8ff;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mc-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* disclosure (advanced details) */
|
||||
.mc-disclosure {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mc-disclosure+.mc-disclosure {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mc-disclosure-summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
padding: 12px 14px;
|
||||
font-weight: 600;
|
||||
color: #f0f0f3;
|
||||
}
|
||||
|
||||
.mc-disclosure[open] .mc-disclosure-summary {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.mc-disclosure-body {
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* nested disclosure for raw json */
|
||||
.mc-disclosure--nested {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.mc-disclosure--nested .mc-disclosure-summary {
|
||||
font-weight: 500;
|
||||
color: #d8d8de;
|
||||
}
|
||||
|
||||
.mc-pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
max-height: 40dvh;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
border-radius: 8px;
|
||||
background: #0f0f12;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* responsive adjustments */
|
||||
@media (max-width: 520px) {
|
||||
.mc-header {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.mc-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mc-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-model-card {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid color-mix(in srgb, var(--border), transparent 30%);
|
||||
background: linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--accent) 12%, transparent),
|
||||
color-mix(in srgb, var(--card) 92%, transparent));
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
||||
outline: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-model-card:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
box-shadow: 0 6px 20px rgba(15, 27, 51, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.selected-model-card:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 65%, white 20%);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.selected-model-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.selected-model-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selected-model-desc {
|
||||
font-size: 0.78rem;
|
||||
color: color-mix(in srgb, var(--muted) 85%, var(--text));
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.selected-model-hint {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.75rem;
|
||||
color: color-mix(in srgb, var(--accent) 70%, var(--text));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.selected-model-hint::after {
|
||||
content: '↗';
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
function generateModelCard(model, options = {}) {
|
||||
const mount = options.mountSelector
|
||||
? /** @type {HTMLElement} */(document.querySelector(options.mountSelector))
|
||||
: document.body;
|
||||
|
||||
if (!mount) throw new Error('mount element not found');
|
||||
|
||||
// overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'mc-overlay';
|
||||
overlay.setAttribute('data-testid', 'model-card-overlay');
|
||||
|
||||
// dialog shell
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'mc-dialog';
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
|
||||
const titleId = `mc-title-${Math.random().toString(36).slice(2)}`,
|
||||
descId = `mc-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 = 'mc-header';
|
||||
|
||||
const titleWrap = document.createElement('div');
|
||||
titleWrap.className = 'mc-titlewrap';
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'mc-avatar';
|
||||
if (model?.info?.meta?.profile_image_url) {
|
||||
avatar.src = model.info.meta.profile_image_url;
|
||||
avatar.alt = '';
|
||||
} else {
|
||||
avatar.src = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||
avatar.alt = '';
|
||||
avatar.classList.add('mc-avatar--placeholder');
|
||||
}
|
||||
|
||||
const h1 = document.createElement('h1');
|
||||
h1.id = titleId;
|
||||
h1.className = 'mc-title';
|
||||
h1.textContent = model?.name ?? model?.id ?? 'model';
|
||||
|
||||
const owner = document.createElement('div');
|
||||
owner.className = 'mc-subtitle';
|
||||
owner.textContent = model?.owned_by ? `by ${model.owned_by}` : '';
|
||||
|
||||
titleWrap.appendChild(avatar);
|
||||
titleWrap.appendChild(h1);
|
||||
if (owner.textContent) titleWrap.appendChild(owner);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'mc-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 = 'mc-body';
|
||||
|
||||
// description (primary)
|
||||
const desc = document.createElement('p');
|
||||
desc.id = descId;
|
||||
desc.className = 'mc-desc';
|
||||
desc.textContent = model?.info?.meta?.description ?? 'no description provided.';
|
||||
|
||||
// badges (primary)
|
||||
const badges = document.createElement('div');
|
||||
badges.className = 'mc-badges';
|
||||
const addBadge = (label, value) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
const b = document.createElement('span');
|
||||
b.className = 'mc-badge';
|
||||
b.textContent = `${label}: ${value}`;
|
||||
badges.appendChild(b);
|
||||
};
|
||||
addBadge('family', model?.ollama?.details?.family ?? model?.ollama?.details?.families?.[0]);
|
||||
addBadge('params', model?.ollama?.details?.parameter_size);
|
||||
addBadge('quant', model?.ollama?.details?.quantization_level);
|
||||
addBadge('format', model?.ollama?.details?.format);
|
||||
addBadge('connection', model?.connection_type ?? model?.ollama?.connection_type);
|
||||
addBadge('created', formatEpoch(model?.created));
|
||||
addBadge('modified', formatIso(model?.ollama?.modified_at));
|
||||
|
||||
// capabilities (primary)
|
||||
const caps = model?.info?.meta?.capabilities ?? {};
|
||||
const capsWrap = document.createElement('div');
|
||||
capsWrap.className = 'mc-section';
|
||||
|
||||
const capsTitle = document.createElement('h2');
|
||||
capsTitle.className = 'mc-section-title';
|
||||
capsTitle.textContent = 'capabilities';
|
||||
|
||||
const capsList = document.createElement('ul');
|
||||
capsList.className = 'mc-capabilities';
|
||||
Object.entries(caps).forEach(([k, v]) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'mc-cap';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'mc-cap-label';
|
||||
label.textContent = k.replaceAll('_', ' ');
|
||||
const state = document.createElement('span');
|
||||
state.className = `mc-cap-state ${v ? 'is-yes' : 'is-no'}`;
|
||||
state.textContent = v ? 'yes' : 'no';
|
||||
li.appendChild(label);
|
||||
li.appendChild(state);
|
||||
capsList.appendChild(li);
|
||||
});
|
||||
capsWrap.appendChild(capsTitle);
|
||||
capsWrap.appendChild(capsList);
|
||||
|
||||
// advanced details (moved to disclosure)
|
||||
const adv = document.createElement('details');
|
||||
adv.className = 'mc-disclosure';
|
||||
adv.setAttribute('data-testid', 'model-card-advanced');
|
||||
|
||||
const advSum = document.createElement('summary');
|
||||
advSum.className = 'mc-disclosure-summary';
|
||||
advSum.textContent = 'advanced details';
|
||||
adv.appendChild(advSum);
|
||||
|
||||
const advBody = document.createElement('div');
|
||||
advBody.className = 'mc-disclosure-body';
|
||||
|
||||
// details table (now inside disclosure)
|
||||
const table = document.createElement('table');
|
||||
table.className = 'mc-table';
|
||||
const tbody = document.createElement('tbody'),
|
||||
rows = [
|
||||
['id', model?.id],
|
||||
['object', model?.object],
|
||||
['digest', model?.ollama?.digest],
|
||||
['size', formatBytes(model?.ollama?.size)],
|
||||
['parameter size', model?.ollama?.details?.parameter_size],
|
||||
['quantization', model?.ollama?.details?.quantization_level],
|
||||
['base model id', safeText(model?.info?.base_model_id) ?? '—'],
|
||||
['function calling', safeText(model?.info?.params?.function_calling)],
|
||||
['active', String(model?.info?.is_active ?? model?.info?.isActive ?? '—')],
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
// urls (inside disclosure; only if valid strings)
|
||||
const urlSection = document.createElement('div');
|
||||
urlSection.className = 'mc-section';
|
||||
if (Array.isArray(model?.ollama?.urls) && model.ollama.urls.some(u => u && typeof u === 'string')) {
|
||||
const urlsTitle = document.createElement('h2');
|
||||
urlsTitle.className = 'mc-section-title';
|
||||
urlsTitle.textContent = 'urls';
|
||||
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'mc-links';
|
||||
|
||||
model.ollama.urls.forEach(u => {
|
||||
if (!u || typeof u !== 'string') return;
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.target = '_blank';
|
||||
a.href = u;
|
||||
a.textContent = u;
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
urlSection.appendChild(urlsTitle);
|
||||
urlSection.appendChild(list);
|
||||
}
|
||||
|
||||
// raw json (inside disclosure)
|
||||
const rawWrap = document.createElement('details');
|
||||
rawWrap.className = 'mc-disclosure mc-disclosure--nested';
|
||||
|
||||
const rawSum = document.createElement('summary');
|
||||
rawSum.className = 'mc-disclosure-summary';
|
||||
rawSum.textContent = 'raw model json';
|
||||
|
||||
rawWrap.appendChild(rawSum);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'mc-pre';
|
||||
pre.textContent = prettyJson(model);
|
||||
rawWrap.appendChild(pre);
|
||||
|
||||
// assemble disclosure body
|
||||
advBody.appendChild(table);
|
||||
if (urlSection.childNodes.length) advBody.appendChild(urlSection);
|
||||
advBody.appendChild(rawWrap);
|
||||
adv.appendChild(advBody);
|
||||
|
||||
// assemble modal content
|
||||
body.appendChild(desc);
|
||||
body.appendChild(badges);
|
||||
body.appendChild(capsWrap);
|
||||
body.appendChild(adv); // advanced section at the very end
|
||||
|
||||
dialog.appendChild(header);
|
||||
dialog.appendChild(body);
|
||||
overlay.appendChild(dialog);
|
||||
mount.appendChild(overlay);
|
||||
|
||||
// focus management (trap + restore)
|
||||
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 formatBytes(n) {
|
||||
if (typeof n !== 'number') return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0, v = n;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
const formatEpoch = (epochSeconds) => {
|
||||
if (!Number.isFinite(epochSeconds)) return '—';
|
||||
try { return new Date(epochSeconds * 1000).toLocaleString(); } catch { return '—'; }
|
||||
}
|
||||
|
||||
function formatIso(iso) {
|
||||
if (!iso || typeof iso !== 'string') return '—';
|
||||
const d = new Date(iso);
|
||||
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
const safeText = (v) => { return v === undefined || v === null ? null : String(v); },
|
||||
prettyJson = (obj) => {
|
||||
try { return JSON.stringify(obj, null, 2); } catch { return String(obj); }
|
||||
}
|
||||
|
||||
|
||||
const makeOptionLabel = (m, models) => {
|
||||
const base = m?.name ?? m?.id ?? 'model',
|
||||
duplicates = models.filter(x => (x?.name ?? x?.id) === (m?.name ?? m?.id));
|
||||
|
||||
if (duplicates.length > 1) {
|
||||
const hint = m?.ollama?.details?.parameter_size || m?.ollama?.details?.quantization_level;
|
||||
return hint ? `${base} • ${hint}` : `${base} • ${String(m?.id).slice(0, 8)}`;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
// render options
|
||||
function renderModelList(models) {
|
||||
const sel = document.querySelector('#model-select'),
|
||||
card = document.querySelector('#selected-model-card');
|
||||
|
||||
if (!sel) {
|
||||
console.warn('failed to find model dropdown');
|
||||
return;
|
||||
}
|
||||
|
||||
const list = Array.isArray(models) ? models : [];
|
||||
sel.__modelsData = list;
|
||||
if (card) card.__modelsData = list;
|
||||
|
||||
const prevValue = sel.value;
|
||||
for (let i = sel.options.length - 1; i >= 1; i--) sel.remove(i);
|
||||
|
||||
list.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(m?.id ?? '');
|
||||
opt.textContent = makeOptionLabel(m, list);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
const selectedExists = list.some(m => String(m?.id ?? '') === prevValue);
|
||||
sel.value = selectedExists ? prevValue : '';
|
||||
|
||||
const updateCard = (id) => {
|
||||
if (!card) return;
|
||||
const registry = card.__modelsData || [],
|
||||
model = registry.find(m => String(m?.id ?? '') === id);
|
||||
|
||||
if (!id || !model) {
|
||||
card.hidden = true;
|
||||
card.dataset.modelId = '';
|
||||
card.replaceChildren();
|
||||
card.setAttribute('aria-label', 'No model selected');
|
||||
return;
|
||||
}
|
||||
|
||||
card.hidden = false;
|
||||
card.dataset.modelId = id;
|
||||
card.setAttribute('aria-label', `Selected model ${makeOptionLabel(model, registry)}`);
|
||||
card.replaceChildren();
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'selected-model-title';
|
||||
title.textContent = model?.name ?? model?.id ?? 'model';
|
||||
|
||||
const metaParts = [];
|
||||
if (model?.owned_by) metaParts.push(model.owned_by);
|
||||
|
||||
const paramSize = model?.ollama?.details?.parameter_size;
|
||||
if (paramSize) metaParts.push(paramSize);
|
||||
|
||||
const quant = model?.ollama?.details?.quantization_level;
|
||||
if (quant) metaParts.push(quant);
|
||||
|
||||
const metaText = metaParts.join(' • '),
|
||||
descRaw = (model?.info?.meta?.description ?? '').trim(),
|
||||
descText = descRaw.length > 90 ? `${descRaw.slice(0, 87).trim()}…` : descRaw;
|
||||
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'selected-model-hint';
|
||||
hint.textContent = 'Open model details';
|
||||
|
||||
card.appendChild(title);
|
||||
if (metaText) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'selected-model-meta';
|
||||
meta.textContent = metaText;
|
||||
card.appendChild(meta);
|
||||
}
|
||||
|
||||
if (descText) {
|
||||
const desc = document.createElement('span');
|
||||
desc.className = 'selected-model-desc';
|
||||
desc.textContent = descText;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
|
||||
card.appendChild(hint);
|
||||
};
|
||||
|
||||
if (!sel.__modelChangeHandler) {
|
||||
sel.__modelChangeHandler = (e) => {
|
||||
const target = /** @type {HTMLSelectElement} */(e.currentTarget),
|
||||
id = target.value;
|
||||
|
||||
if (id) updateCard(id);
|
||||
|
||||
// const registry = sel.__modelsData || [],
|
||||
// model = registry.find(m => String(m?.id ?? '') === id);
|
||||
|
||||
// if (!model) return;
|
||||
// generateModelCard(model, { trigger: sel });
|
||||
};
|
||||
sel.addEventListener('change', sel.__modelChangeHandler);
|
||||
}
|
||||
|
||||
if (card && !card.dataset.bound) {
|
||||
card.addEventListener('click', () => {
|
||||
const id = card.dataset.modelId;
|
||||
if (!id) return;
|
||||
const registry = card.__modelsData || [];
|
||||
const model = registry.find(m => String(m?.id ?? '') === id);
|
||||
if (model) generateModelCard(model, { trigger: card });
|
||||
});
|
||||
card.dataset.bound = 'true';
|
||||
}
|
||||
|
||||
updateCard(sel.value);
|
||||
}
|
||||
+444
-124
@@ -1,8 +1,19 @@
|
||||
const storedMe = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("me") ?? localStorage.getItem("user");
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const state = {
|
||||
userId: localStorage.getItem("userId") || "",
|
||||
displayName: localStorage.getItem("displayName") || "",
|
||||
me: storedMe,
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
const $ = (sel) => document.querySelector(sel),
|
||||
setText = (sel, v) => {
|
||||
const el = $(sel);
|
||||
@@ -14,74 +25,314 @@ const authStatusEl = $("#authStatus"),
|
||||
createStatusEl = $("#createStatus"),
|
||||
runNowStatusEl = $("#runNowStatus"),
|
||||
schedulesTbody = $("#schedulesTbody"),
|
||||
templatesUl = $("#templatesUl");
|
||||
templatesUl = $("#templatesUl"),
|
||||
oneShotCheckmark = $("#oneShot"),
|
||||
startAtInput = $("#startAt"),
|
||||
cronInput = $("#cron"),
|
||||
cronError = $("#cronError"),
|
||||
filesInput = $("#scheduleFiles"),
|
||||
filesList = $("#scheduleFilesList");
|
||||
|
||||
// update login ui from state
|
||||
function paintAuth() {
|
||||
$("#userId").value = state.userId || "";
|
||||
$("#displayName").value = state.displayName || "";
|
||||
if (state.userId) {
|
||||
authStatusEl.textContent = `logged in as ${state.displayName ? state.displayName + " · " : ""
|
||||
}${state.userId}`;
|
||||
const cron5Regex = new RegExp(
|
||||
'^' +
|
||||
'(\\*|([0-5]?\\d)(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
|
||||
'(\\*|([01]?\\d|2[0-3])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
|
||||
'(\\*|([1-9]|[12]\\d|3[01])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
|
||||
'(\\*|([1-9]|1[0-2])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
|
||||
'(\\*|[0-7](\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))' +
|
||||
'$'
|
||||
);
|
||||
|
||||
const validateCron = (e) => {
|
||||
if (!cronInput || !cronError) return true;
|
||||
const val = cronInput.value.trim();
|
||||
if (cron5Regex.test(val)) {
|
||||
cronError.style.display = 'none';
|
||||
cronError.textContent = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
e?.preventDefault();
|
||||
cronError.textContent = 'please enter a valid cron expression';
|
||||
cronError.style.display = 'block';
|
||||
return false;
|
||||
};
|
||||
|
||||
cronInput?.addEventListener('input', () => {
|
||||
if (!cronError) return;
|
||||
const val = cronInput.value.trim();
|
||||
if (cron5Regex.test(val)) {
|
||||
cronError.style.display = 'none';
|
||||
cronError.textContent = '';
|
||||
} else {
|
||||
authStatusEl.textContent = "not logged in";
|
||||
cronError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
const size = Number(bytes);
|
||||
if (!Number.isFinite(size) || size < 0) return '';
|
||||
if (size === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const idx = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
|
||||
const scaled = size / Math.pow(1024, idx);
|
||||
return `${scaled.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
};
|
||||
|
||||
const readFileAsDataUrl = (file) => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error(`failed to read file ${file?.name || ''}`));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const renderSelectedFiles = () => {
|
||||
if (!filesList) return;
|
||||
const files = filesInput?.files ? Array.from(filesInput.files) : [];
|
||||
if (!files.length) {
|
||||
filesList.innerHTML = '<li class="muted empty">no files selected</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
filesList.innerHTML = '';
|
||||
files.forEach((file) => {
|
||||
const li = document.createElement('li');
|
||||
const size = formatBytes(file.size);
|
||||
li.textContent = size ? `${file.name} (${size})` : file.name;
|
||||
filesList.appendChild(li);
|
||||
});
|
||||
};
|
||||
|
||||
filesInput?.addEventListener('change', renderSelectedFiles);
|
||||
renderSelectedFiles();
|
||||
|
||||
// JSON
|
||||
const paramsEl = $('#params'),
|
||||
options = {
|
||||
mode: 'code',
|
||||
modes: ['code', 'form', 'text'], // allowed modes
|
||||
onModeChange: function (newMode, oldMode) {
|
||||
console.log('Mode switched from', oldMode, 'to', newMode)
|
||||
}
|
||||
},
|
||||
jInstance = new JSONEditor(paramsEl, options)
|
||||
|
||||
// set json
|
||||
const initialJson = {
|
||||
"report_kind": "summary",
|
||||
"Numbers": [1, 2, 3]
|
||||
}
|
||||
jInstance.set(initialJson);
|
||||
window.jInstance = jInstance;
|
||||
|
||||
const togCron = ({ target: { checked } }) => {
|
||||
const cron = cronInput?.parentElement,
|
||||
start = startAtInput?.parentElement,
|
||||
warn = $(".warning-box")?.parentElement;
|
||||
|
||||
if (!cron || !start || !warn) return;
|
||||
|
||||
if (checked) {
|
||||
cron.style.display = 'none';
|
||||
warn.style.display = 'none';
|
||||
start.style.display = 'block';
|
||||
if (startAtInput) startAtInput.required = true;
|
||||
if (cronInput) cronInput.required = false;
|
||||
} else {
|
||||
cron.style.display = 'block';
|
||||
warn.style.display = 'flex';
|
||||
start.style.display = 'block';
|
||||
if (startAtInput) startAtInput.required = false;
|
||||
if (cronInput) cronInput.required = true;
|
||||
}
|
||||
}
|
||||
|
||||
oneShotCheckmark.addEventListener('change', togCron);
|
||||
togCron({ target: { checked: oneShotCheckmark.checked } });
|
||||
|
||||
// update login ui from state
|
||||
async function paintAuth() {
|
||||
const meRes = await fetch('/api/me', {
|
||||
headers: {
|
||||
'credentials': 'include',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (meRes.ok) {
|
||||
const me = await meRes.json().catch(console.error);
|
||||
if (me) {
|
||||
localStorage.setItem("me", JSON.stringify(me));
|
||||
state.me = me;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.me && state.me.id) {
|
||||
const who = state.me.name || state.me.email || state.me.id;
|
||||
authStatusEl.textContent = `logged in as ${who}`;
|
||||
document.querySelector('#userLogoutBtn').style.display = 'block';
|
||||
|
||||
// populate user-card
|
||||
const userCard = $("#userCard"),
|
||||
avatar = $("#userAvatar"),
|
||||
nameEl = $("#userName"),
|
||||
emailEl = $("#userEmail"),
|
||||
roleEl = $("#userRole"),
|
||||
permsEl = $("#userPermissions");
|
||||
|
||||
if (state.me.models) renderModelList(state.me.models);
|
||||
if (state.me.tools) renderToolList(state.me.tools);
|
||||
const featurePerms = state.me.permissions?.features;
|
||||
renderFeatureList(featurePerms);
|
||||
|
||||
if (userCard) userCard.setAttribute("aria-hidden", "false");
|
||||
if (avatar) {
|
||||
avatar.src = state.me.profile_image_url || "";
|
||||
avatar.alt = state.me.name || state.me.email || "user avatar";
|
||||
}
|
||||
if (nameEl) nameEl.textContent = state.me.name || state.me.email || state.me.id;
|
||||
if (emailEl) emailEl.textContent = state.me.email || "";
|
||||
if (roleEl) roleEl.textContent = state.me.role ? `role: ${state.me.role}` : "";
|
||||
|
||||
if (permsEl) {
|
||||
permsEl.hidden = false;
|
||||
renderSelectedPermissions(state.me, permsEl);
|
||||
}
|
||||
|
||||
document.querySelector('#auth').ariaHidden = 'true';
|
||||
} else {
|
||||
authStatusEl.textContent = "not logged in";
|
||||
document.querySelector('#userLogoutBtn').style.display = 'none';
|
||||
|
||||
// clear credential inputs if present
|
||||
const u = $("#username"),
|
||||
p = $("#password");
|
||||
if (u) u.value = "";
|
||||
if (p) p.value = "";
|
||||
|
||||
// hide user-card
|
||||
const userCard = $("#userCard"),
|
||||
avatar = $("#userAvatar"),
|
||||
nameEl = $("#userName"),
|
||||
emailEl = $("#userEmail"),
|
||||
roleEl = $("#userRole"),
|
||||
permsEl = $("#userPermissions");
|
||||
|
||||
if (userCard) userCard.setAttribute("aria-hidden", "true");
|
||||
if (avatar) { avatar.src = ""; avatar.alt = ""; }
|
||||
if (nameEl) nameEl.textContent = "not logged in";
|
||||
if (emailEl) emailEl.textContent = "";
|
||||
if (roleEl) roleEl.textContent = "";
|
||||
if (permsEl) {
|
||||
permsEl.hidden = true;
|
||||
// clear values
|
||||
permsEl.querySelectorAll(".perm-val").forEach(el => el.textContent = "");
|
||||
}
|
||||
|
||||
renderFeatureList();
|
||||
}
|
||||
}
|
||||
|
||||
// helper to safely resolve nested permission path like "features.web_search"
|
||||
function getPerm(me, path) {
|
||||
if (!me || !me.permissions || !path) return false;
|
||||
return path.split('.').reduce((acc, k) => (acc && typeof acc === 'object') ? acc[k] : undefined, me.permissions) || false;
|
||||
}
|
||||
|
||||
function renderSelectedPermissions(me, container) {
|
||||
// mapping of displayed items -> permission path
|
||||
const items = [
|
||||
{ path: 'workspace.tools', label: 'Workspace (tools)' },
|
||||
{ path: 'chat.file_upload', label: 'Chat (file_upload)' },
|
||||
{ path: 'features.web_search', label: 'web_search' },
|
||||
{ path: 'features.code_interpreter', label: 'code_interpreter' }
|
||||
],
|
||||
isadmin = me.role === "admin";
|
||||
|
||||
// update all .perm-val placeholders by data-perm attribute when present
|
||||
container.querySelectorAll('.perm-val').forEach(el => {
|
||||
const key = el.getAttribute('data-perm');
|
||||
if (key) {
|
||||
const v = isadmin || getPerm(me, key);
|
||||
el.textContent = v ? '✅' : '❌';
|
||||
el.classList.toggle('perm-yes', Boolean(v));
|
||||
el.classList.toggle('perm-no', !v);
|
||||
el.title = `${key}: ${v ? 'allowed' : 'denied'}`;
|
||||
}
|
||||
});
|
||||
|
||||
// fallback placeholders
|
||||
items.forEach(it => {
|
||||
const sel = `[data-perm="${it.path}"]`;
|
||||
if (!container.querySelector(sel)) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="perm-val ${getPerm(me, it.path) ? 'perm-yes' : 'perm-no'}">${isadmin || getPerm(me, it.path) ? '✅' : '❌'}</span>`;
|
||||
li.innerHTML += `<span class="perm-key">${escapeHtml(it.label)}</span>`;
|
||||
container.querySelector('.perms-list')?.appendChild(li);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// theme toggle stuff!
|
||||
const prefersLight = document.defaultView?.matchMedia?.('(prefers-color-scheme: light)').matches === true,
|
||||
saved = localStorage.getItem('theme'),
|
||||
initial = saved ?? (prefersLight ? 'light' : 'dark');
|
||||
const prefersLight = document.defaultView?.matchMedia?.("(prefers-color-scheme: light)").matches === true,
|
||||
saved = localStorage.getItem("theme"),
|
||||
initial = saved ?? (prefersLight ? "light" : "dark");
|
||||
|
||||
const applyTheme = (mode) => {
|
||||
const b = document.body;
|
||||
b.classList.remove('theme-dark', 'theme-light');
|
||||
b.classList.add(mode === 'dark' ? 'theme-dark' : 'theme-light');
|
||||
b.classList.remove("theme-dark", "theme-light");
|
||||
b.classList.add(mode === "dark" ? "theme-dark" : "theme-light");
|
||||
|
||||
const icon = document.querySelector('#themeToggleIcon'),
|
||||
label = document.querySelector('#themeToggleLabel');
|
||||
const icon = document.querySelector("#themeToggleIcon"),
|
||||
label = document.querySelector("#themeToggleLabel");
|
||||
if (icon && label) {
|
||||
const isDark = mode === 'dark';
|
||||
icon.textContent = isDark ? '🌙' : '☀️';
|
||||
label.textContent = isDark ? 'Dark' : 'Light';
|
||||
const isDark = mode === "dark";
|
||||
icon.textContent = isDark ? "🌙" : "☀️";
|
||||
label.textContent = isDark ? "Dark" : "Light";
|
||||
}
|
||||
|
||||
document.querySelector('#themeToggle')?.setAttribute('aria-pressed', String(mode === 'dark'));
|
||||
document.querySelector("#themeToggle")?.setAttribute("aria-pressed", String(mode === "dark"));
|
||||
};
|
||||
|
||||
applyTheme(initial);
|
||||
|
||||
document.querySelector('#themeToggle')?.addEventListener('click', () => {
|
||||
const next = document.body.classList.contains('theme-dark') ? 'light' : 'dark';
|
||||
document.querySelector("#themeToggle")?.addEventListener("click", () => {
|
||||
const next = document.body.classList.contains("theme-dark") ? "light" : "dark";
|
||||
applyTheme(next);
|
||||
try { localStorage.setItem('theme', next); } catch (_) { /* ignore */ }
|
||||
try {
|
||||
localStorage.setItem("theme", next);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mq.addEventListener?.("change", (e) => {
|
||||
// only adapt to system changes when user hasn't explicitly chosen a theme
|
||||
if (!localStorage.getItem('theme')) applyTheme(e.matches ? 'dark' : 'light');
|
||||
if (!localStorage.getItem("theme")) applyTheme(e.matches ? "dark" : "light");
|
||||
});
|
||||
}
|
||||
|
||||
// wrap fetch to always attach x-user-id
|
||||
// wrap fetch to always attach x-user-id and Authorization when available
|
||||
async function apiFetch(url, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!state.userId)
|
||||
throw new Error(
|
||||
"no user id set — use the login form first"
|
||||
);
|
||||
headers.set("x-user-id", state.userId); // custom header
|
||||
if (
|
||||
!headers.has("content-type") &&
|
||||
options.body &&
|
||||
!(options.body instanceof FormData)
|
||||
) {
|
||||
if (!state.me || !state.me.id) {
|
||||
throw new Error("no authenticated user — use the login form first");
|
||||
}
|
||||
|
||||
headers.set("x-user-id", state.me.id); // keep existing custom header for server compatibility
|
||||
if (state.me.token) {
|
||||
const typ = state.me.token_type || "Bearer";
|
||||
headers.set("authorization", `${typ} ${state.me.token}`);
|
||||
}
|
||||
|
||||
if (!headers.has("content-type") && options.body && !(options.body instanceof FormData)) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
headers.set('credentials', 'include');
|
||||
|
||||
const resp = await fetch(url, { ...options, headers });
|
||||
if (!resp.ok) {
|
||||
// try to surface json error bodies
|
||||
@@ -99,67 +350,117 @@ async function apiFetch(url, options = {}) {
|
||||
function renderSchedules(items = []) {
|
||||
schedulesTbody.innerHTML = "";
|
||||
items.forEach((it) => {
|
||||
const tr = document.createElement("tr");
|
||||
const tRef = it.templateRef
|
||||
? it.templateRef.clusterScope
|
||||
? `(cluster) ${it.templateRef.name}`
|
||||
: it.templateRef.name
|
||||
: "";
|
||||
const tr = document.createElement("tr"),
|
||||
tRef = it.templateRef
|
||||
? it.templateRef.clusterScope
|
||||
? `(cluster) ${it.templateRef.name}`
|
||||
: it.templateRef.name
|
||||
: "";
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>${escapeHtml(it.displayName || it.name || "")}</td>
|
||||
<td>${(it.schedules || []).map(escapeHtml).join("<br/>")}</td>
|
||||
<td>${escapeHtml(it.timezone || "")}</td>
|
||||
<td>${escapeHtml(it.startAt || "")}</td>
|
||||
<td>${escapeHtml(tRef)}</td>
|
||||
<td>${escapeHtml(it.entrypoint || "")}</td>
|
||||
<td>${escapeHtml(it.prompt || "")}</td>
|
||||
<td>${it.oneShot ? "yes" : "no"}</td>
|
||||
<td class="actions">
|
||||
<button data-del="${encodeURIComponent(it.name)}">delete</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
schedulesTbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// tiny escape helper
|
||||
function escapeHtml(s = "") {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
return String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
// wire up events
|
||||
$("#loginForm").addEventListener("submit", (e) => {
|
||||
$("#loginForm").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const userId = $("#userId").value.trim(),
|
||||
displayName = $("#displayName").value.trim();
|
||||
if (!userId) {
|
||||
authStatusEl.textContent = "please enter a user id";
|
||||
const username = $("#username").value.trim(),
|
||||
password = $("#password").value;
|
||||
|
||||
if (!username || !password) {
|
||||
authStatusEl.textContent = "please enter username and password";
|
||||
return;
|
||||
}
|
||||
state.userId = userId;
|
||||
state.displayName = displayName;
|
||||
localStorage.setItem("userId", state.userId);
|
||||
localStorage.setItem("displayName", state.displayName);
|
||||
paintAuth();
|
||||
|
||||
try {
|
||||
authStatusEl.textContent = "authenticating...";
|
||||
const res = await fetch("/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const d = await res.json();
|
||||
if (d && d.error) msg = d.error;
|
||||
} catch { }
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const data = await res.json(),
|
||||
me = data.me || data.user || data;
|
||||
if (!me || !me.id) {
|
||||
throw new Error("invalid login response (missing id)");
|
||||
}
|
||||
|
||||
// // persist cookie if server returned one
|
||||
// if (data.cookie) {
|
||||
// try {
|
||||
// document.cookie = `${document.cookie.replace(/owebucookie=.*;/, '')}owebucookie=${data.cookie}`;
|
||||
// } catch { }
|
||||
// }
|
||||
|
||||
// store canonical "me"
|
||||
try {
|
||||
localStorage.setItem("me", JSON.stringify(me));
|
||||
// keep old key for compatibility
|
||||
localStorage.setItem("user", JSON.stringify(me));
|
||||
} catch { }
|
||||
|
||||
state.me = me;
|
||||
paintAuth();
|
||||
|
||||
// trigger refresh immediately
|
||||
$("#refreshBtn")?.click();
|
||||
} catch (err) {
|
||||
authStatusEl.textContent = `login error: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
$("#logoutBtn").addEventListener("click", () => {
|
||||
localStorage.removeItem("userId");
|
||||
localStorage.removeItem("displayName");
|
||||
state.userId = "";
|
||||
state.displayName = "";
|
||||
// central logout routine used by multiple buttons
|
||||
function doLogout() {
|
||||
try {
|
||||
localStorage.removeItem("me");
|
||||
localStorage.removeItem("user");
|
||||
} catch { }
|
||||
state.me = null;
|
||||
paintAuth();
|
||||
});
|
||||
}
|
||||
|
||||
// existing logout button (if present) + user card logout
|
||||
const logoutBtn = $("#logoutBtn"),
|
||||
userLogoutBtn = $("#userLogoutBtn");
|
||||
if (logoutBtn) logoutBtn.addEventListener("click", doLogout);
|
||||
if (userLogoutBtn) userLogoutBtn.addEventListener("click", doLogout);
|
||||
|
||||
$("#refreshBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
listStatusEl.textContent = "loading...";
|
||||
const res = await apiFetch("/api/schedules");
|
||||
const data = await res.json();
|
||||
|
||||
const res = await apiFetch("/api/schedules"),
|
||||
data = await res.json();
|
||||
|
||||
renderSchedules(data.items || []);
|
||||
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0
|
||||
} schedule(s)`;
|
||||
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0} schedule(s)`;
|
||||
} catch (e) {
|
||||
listStatusEl.textContent = `error: ${e.message}`;
|
||||
}
|
||||
@@ -175,7 +476,7 @@ schedulesTbody.addEventListener("click", async (e) => {
|
||||
|
||||
try {
|
||||
target.disabled = true;
|
||||
const res = await apiFetch(`/schedules/${name}`, {
|
||||
const res = await apiFetch(`/api/schedules/${name}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -198,17 +499,36 @@ $("#createForm").addEventListener("submit", async (e) => {
|
||||
try {
|
||||
createStatusEl.textContent = "saving...";
|
||||
const name = $("#name").value.trim(),
|
||||
tz = $("#tz").value.trim() || "America/New_York",
|
||||
iso = $("#iso").value
|
||||
? new Date($("#iso").value).toISOString()
|
||||
: "",
|
||||
startAt = startAtInput?.value || "",
|
||||
cron = $("#cron").value.trim(),
|
||||
templateName = $("#templateName").value.trim(),
|
||||
entrypoint = $("#entrypoint").value.trim(),
|
||||
prompt = $("#prompt").value.trim(),
|
||||
clusterScope = $("#clusterScope").checked,
|
||||
oneShot = $("#oneShot").checked,
|
||||
paramsRaw = $("#params").value.trim();
|
||||
paramsRaw = jInstance.getText(),
|
||||
model = $("#model-select").value.trim(),
|
||||
tools = Array.from(document
|
||||
.querySelectorAll("#tools-select [aria-selected='true']"))
|
||||
.map(o => o.dataset.toolId),
|
||||
featuresInput = $("#features-select-input");
|
||||
|
||||
if (!oneShot && !cron) {
|
||||
throw new Error("cron expression is required");
|
||||
}
|
||||
|
||||
if (!oneShot && !validateCron(e)) return;
|
||||
|
||||
if (oneShot && !startAt) {
|
||||
throw new Error("start date is required for one-shot schedules");
|
||||
}
|
||||
|
||||
const jErrs = await jInstance.validate();
|
||||
if (jErrs?.length) {
|
||||
console.error(jErrs);
|
||||
throw new Error(`Please fix your JSON errors!`);
|
||||
}
|
||||
|
||||
// leave as extra validation
|
||||
let parameters = {};
|
||||
if (paramsRaw) {
|
||||
try {
|
||||
@@ -218,64 +538,62 @@ $("#createForm").addEventListener("submit", async (e) => {
|
||||
}
|
||||
}
|
||||
|
||||
let features = {};
|
||||
if (featuresInput && featuresInput.value) {
|
||||
try {
|
||||
const parsed = JSON.parse(featuresInput.value);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
features = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, Boolean(v)]));
|
||||
}
|
||||
} catch {
|
||||
throw new Error("features selection must be valid json");
|
||||
}
|
||||
}
|
||||
|
||||
const attachments = filesInput?.files ? Array.from(filesInput.files) : [];
|
||||
const filesPayload = await Promise.all(attachments.map(async (file) => ({
|
||||
fname: file.name,
|
||||
content: await readFileAsDataUrl(file)
|
||||
})));
|
||||
|
||||
const when = oneShot ? { start: startAt } : { cron };
|
||||
if (!oneShot && startAt) when.start = startAt;
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
when: cron ? { cron } : { iso },
|
||||
tz,
|
||||
when,
|
||||
oneShot,
|
||||
template: { name: templateName, clusterScope },
|
||||
parameters,
|
||||
entrypoint: entrypoint || undefined,
|
||||
prompt,
|
||||
model,
|
||||
tools,
|
||||
features
|
||||
};
|
||||
if (filesPayload.length) payload.files = filesPayload;
|
||||
|
||||
await apiFetch("/schedules", {
|
||||
await apiFetch("/api/schedules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
createStatusEl.textContent = "saved ✅";
|
||||
$("#refreshBtn").click();
|
||||
|
||||
// clear the form
|
||||
$("#createForm").reset();
|
||||
togCron({ target: { checked: $("#oneShot").checked } });
|
||||
renderSelectedFiles();
|
||||
if (cronError) {
|
||||
cronError.style.display = 'none';
|
||||
cronError.textContent = '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createStatusEl.textContent = `error: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
// run now
|
||||
$("#runNowForm").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
runNowStatusEl.textContent = "starting...";
|
||||
const name = $("#rnName").value.trim() || "ad-hoc",
|
||||
templateName = $("#rnTemplateName").value.trim(),
|
||||
entrypoint = $("#rnEntrypoint").value.trim(),
|
||||
clusterScope = $("#rnClusterScope").checked,
|
||||
paramsRaw = $("#rnParams").value.trim();
|
||||
|
||||
let parameters = {};
|
||||
if (paramsRaw) {
|
||||
try {
|
||||
parameters = JSON.parse(paramsRaw);
|
||||
} catch {
|
||||
throw new Error("parameters must be valid json");
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
template: { name: templateName, clusterScope },
|
||||
entrypoint: entrypoint || undefined,
|
||||
parameters,
|
||||
};
|
||||
|
||||
await apiFetch("/run-now", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
runNowStatusEl.textContent = "started ✅";
|
||||
} catch (err) {
|
||||
runNowStatusEl.textContent = `error: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
// load workflow templates for convenience
|
||||
$("#loadTemplatesBtn").addEventListener("click", async () => {
|
||||
@@ -292,14 +610,16 @@ $("#loadTemplatesBtn").addEventListener("click", async () => {
|
||||
templatesUl.appendChild(li);
|
||||
});
|
||||
} catch (e) {
|
||||
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(
|
||||
e.message
|
||||
)}</li>`;
|
||||
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(e.message)}</li>`;
|
||||
}
|
||||
});
|
||||
|
||||
// boot
|
||||
paintAuth();
|
||||
// window.onbeforeunload = () => CookieStore.delete('token');
|
||||
|
||||
// auto-refresh if already logged in
|
||||
if (state.userId) $("#refreshBtn").click();
|
||||
const reffunc = () => {
|
||||
// auto-refresh if already logged in
|
||||
if (state.me && state.me.id) $("#refreshBtn").click();
|
||||
}
|
||||
|
||||
// boot
|
||||
paintAuth().then(reffunc);
|
||||
|
||||
+249
-4
@@ -48,6 +48,12 @@ body.theme-dark {
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--accent: #7aa2ff;
|
||||
--accent-600: #4f7dff;
|
||||
|
||||
/* warn box */
|
||||
--box-bg: #2c2c2c;
|
||||
--box-text: #f5f5f5;
|
||||
--box-border: #ff6b6b;
|
||||
--icon-fill: #ff6b6b;
|
||||
}
|
||||
|
||||
body.theme-light {
|
||||
@@ -61,6 +67,12 @@ body.theme-light {
|
||||
--border: #e5e9f2;
|
||||
--accent: #2563eb;
|
||||
--accent-600: #1e40af;
|
||||
|
||||
/* warn box */
|
||||
--box-bg: #ffffff;
|
||||
--box-text: #000000;
|
||||
--box-border: #d9534f;
|
||||
--icon-fill: #d9534f;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +82,7 @@ body.theme-light {
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -128,7 +140,7 @@ body {
|
||||
/* content */
|
||||
.content-grid {
|
||||
max-width: var(--maxw);
|
||||
margin: 1.2rem auto 2rem;
|
||||
/* margin: 1.2rem auto 2rem; */
|
||||
padding: 0 clamp(1rem, 3vw, 1.25rem);
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
@@ -150,12 +162,17 @@ body {
|
||||
|
||||
.col-right {}
|
||||
|
||||
.templates summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* cards */
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .02)), var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.28), 0 1px 0 rgba(255, 255, 255, .04) inset;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card.compact {
|
||||
@@ -180,6 +197,36 @@ body {
|
||||
gap: .9rem;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: grid;
|
||||
gap: .35rem;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-input input[type="file"] {
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin: .5rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
list-style: disc;
|
||||
color: var(--muted);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.file-list li + li {
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
.file-list .empty::marker {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
gap: .9rem;
|
||||
@@ -193,7 +240,16 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
ul[class="row"] {
|
||||
display: block;
|
||||
line-height: 25px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 10px 0px;
|
||||
}
|
||||
|
||||
label,
|
||||
.label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: .92rem;
|
||||
@@ -331,6 +387,9 @@ td:first-child {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
padding: 10px 0px;
|
||||
width: 100%;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.actions.wrap {
|
||||
@@ -366,6 +425,153 @@ code.inline {
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
/* auth card */
|
||||
.auth-card {
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0 2rem;
|
||||
max-width: 480px;
|
||||
background: var(--panel, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.auth-card h2 {
|
||||
margin: 0 0 .5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.auth-card form {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.auth-card label {
|
||||
display: grid;
|
||||
gap: .25rem;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.auth-card input {
|
||||
padding: .5rem .6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: var(--bg, #fff);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.auth-card .row {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-card button {
|
||||
padding: .45rem .8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #333);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* user card */
|
||||
.user-card {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.user-card .user-compact {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.user-card .avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.user-card .user-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-card .user-name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-card .user-email,
|
||||
.user-card .user-role {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* permissions UI */
|
||||
.permissions {
|
||||
margin-top: .6rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.permissions .small {
|
||||
margin: 0 0 .35rem;
|
||||
font-size: .85rem;
|
||||
color: var(--muted, #666);
|
||||
}
|
||||
|
||||
.perms-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: .25rem;
|
||||
font-size: .9rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.perms-list .perm-key {
|
||||
display: inline-block;
|
||||
min-width: 140px;
|
||||
color: var(--muted, #666);
|
||||
}
|
||||
|
||||
.nested-perms {
|
||||
list-style: none;
|
||||
padding: 0 0 0 .5rem;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: .15rem;
|
||||
}
|
||||
|
||||
.perm-val {
|
||||
font-weight: 700;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
.perm-yes {
|
||||
color: #0b875b;
|
||||
}
|
||||
|
||||
/* green */
|
||||
.perm-no {
|
||||
color: #c23b3b;
|
||||
}
|
||||
|
||||
/* red */
|
||||
|
||||
/* high contrast preference gets stronger focus */
|
||||
@media (prefers-contrast: more) {
|
||||
|
||||
@@ -376,4 +582,43 @@ code.inline {
|
||||
.theme-toggle:focus {
|
||||
box-shadow: 0 0 0 3px #fff, 0 0 0 5px var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Box layout and styling */
|
||||
.warning-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
/* Space between icon and text */
|
||||
gap: 0.6rem;
|
||||
margin-top: 10px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.9rem;
|
||||
color: var(--box-text);
|
||||
background: var(--box-bg);
|
||||
border: 1px solid var(--box-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
max-height: 50px;
|
||||
}
|
||||
|
||||
/* Icon styling */
|
||||
.warning-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
fill: var(--icon-fill);
|
||||
}
|
||||
|
||||
/* make the whole box a bit more readable on hover */
|
||||
.warning-box:hover {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-subsection {
|
||||
border-top: 1px dashed var(--border);
|
||||
padding-top: 1rem;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
:root {
|
||||
/* core palette tuned for strong contrast on very dark ui (aiming for wcag aa) */
|
||||
--tc-bg: #120a09;
|
||||
/* deep near-black with red tint */
|
||||
--tc-surface: #1a0e0b;
|
||||
/* card background */
|
||||
--tc-surface-2: #21110e;
|
||||
/* subtle layered bg */
|
||||
--tc-text: #f3e9e7;
|
||||
/* primary text */
|
||||
--tc-text-subtle: #913413;
|
||||
/* secondary text */
|
||||
--tc-muted: #cbb2aa;
|
||||
--tc-accent: #ff6b3d;
|
||||
/* orange/red accent */
|
||||
--tc-accent-2: #ff3d3d;
|
||||
/* red accent for states */
|
||||
--tc-border: rgba(255, 255, 255, 0.10);
|
||||
--tc-border-2: rgba(255, 255, 255, 0.16);
|
||||
--tc-overlay: rgba(0, 0, 0, 0.55);
|
||||
--tc-link: #ffb083;
|
||||
/* readable on dark bg */
|
||||
}
|
||||
|
||||
.tc-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--tc-overlay);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(12px, 2vw, 24px);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.tc-dialog {
|
||||
width: min(880px, 100%);
|
||||
max-height: 90dvh;
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, var(--tc-surface), var(--tc-surface-2));
|
||||
color: var(--tc-text);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
|
||||
outline: none;
|
||||
border: 1px solid var(--tc-border);
|
||||
}
|
||||
|
||||
.tc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--tc-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
backdrop-filter: blur(6px);
|
||||
background:
|
||||
linear-gradient(to bottom, color-mix(in srgb, var(--tc-surface), transparent 20%), color-mix(in srgb, var(--tc-surface-2), transparent 30%)),
|
||||
conic-gradient(from 0.25turn at 10% -40%, color-mix(in srgb, var(--tc-accent), transparent 85%), transparent 30%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tc-titlewrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tc-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--tc-border-2);
|
||||
background: #2a1512;
|
||||
}
|
||||
|
||||
.tc-avatar--placeholder {
|
||||
background: linear-gradient(135deg, #3b1e19, #2a1512);
|
||||
}
|
||||
|
||||
.tc-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tc-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--tc-muted);
|
||||
}
|
||||
|
||||
.tc-close {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--tc-muted);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tc-close:hover {
|
||||
color: var(--tc-text);
|
||||
border-color: var(--tc-border-2);
|
||||
background: color-mix(in srgb, var(--tc-accent), transparent 86%);
|
||||
}
|
||||
|
||||
.tc-body {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.tc-desc {
|
||||
margin: 0;
|
||||
color: var(--tc-text);
|
||||
}
|
||||
|
||||
.tc-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tc-badge {
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--tc-accent), transparent 88%);
|
||||
border: 1px solid var(--tc-border-2);
|
||||
color: var(--tc-text);
|
||||
}
|
||||
|
||||
.tc-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tc-section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--tc-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tc-manifest {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px 14px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tc-manifest-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--tc-border);
|
||||
}
|
||||
|
||||
.tc-manifest-item a {
|
||||
color: #4da6ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tc-manifest-item a:hover {
|
||||
color: #80c1ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
.tc-manifest-label {
|
||||
color: var(--tc-text-subtle);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.tc-manifest-value {
|
||||
font-size: 0.86rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tc-border-2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.tc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--tc-border);
|
||||
}
|
||||
|
||||
.tc-table th,
|
||||
.tc-table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--tc-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tool-pill-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.tool-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--tc-border);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-pill--selected {
|
||||
background: color-mix(in srgb, var(--tc-accent), transparent 70%);
|
||||
border-color: color-mix(in srgb, var(--tc-accent), transparent 40%);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.tool-pill-toggle {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tool-pill-toggle:focus-visible,
|
||||
.tool-pill-info:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--tc-accent), white 35%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tool-pill-info {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tc-muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tool-pill-info:hover {
|
||||
color: var(--tc-text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tool-pill-info span {
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tool-pill-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tc-table th {
|
||||
width: 30%;
|
||||
color: var(--tc-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tc-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tc-links a {
|
||||
color: var(--tc-link);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tc-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* disclosure (advanced details) */
|
||||
.tc-disclosure {
|
||||
border: 1px solid var(--tc-border);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tc-disclosure+.tc-disclosure {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tc-disclosure-summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
padding: 12px 14px;
|
||||
font-weight: 700;
|
||||
color: var(--tc-text);
|
||||
}
|
||||
|
||||
.tc-disclosure[open] .tc-disclosure-summary {
|
||||
border-bottom: 1px solid var(--tc-border);
|
||||
}
|
||||
|
||||
.tc-disclosure-body {
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* nested disclosure for raw json */
|
||||
.tc-disclosure--nested {
|
||||
border: 1px dashed color-mix(in srgb, var(--tc-accent-2), transparent 65%);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.tc-disclosure--nested .tc-disclosure-summary {
|
||||
font-weight: 600;
|
||||
color: var(--tc-text-subtle);
|
||||
}
|
||||
|
||||
.tc-pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
max-height: 40dvh;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
border-radius: 8px;
|
||||
background: #0d0706;
|
||||
border: 1px solid var(--tc-border);
|
||||
color: var(--tc-text);
|
||||
}
|
||||
|
||||
/* responsive adjustments */
|
||||
@media (max-width: 520px) {
|
||||
.tc-header {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.tc-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tc-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user