// ── AI Backends section: copy, API keys, local service connect ─────────────
(function initLlmsSection() {
// Copy buttons
document.querySelectorAll('.llm-copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const text = btn.dataset.copy || '';
const orig = btn.textContent;
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1500); };
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(done).catch(done);
} else {
const ta = document.createElement('textarea');
ta.value = text; ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
done();
}
});
});
// API key inputs — persist to localStorage, eye toggle, saved badge
document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
const key = inp.dataset.llmKey;
const saved = localStorage.getItem('llm-key-' + key);
if (saved) inp.value = saved;
const eye = document.createElement('button');
eye.type = 'button'; eye.className = 'llm-eye-btn'; eye.title = 'Show / hide';
eye.textContent = '👁';
inp.insertAdjacentElement('afterend', eye);
const badge = document.createElement('span');
badge.className = 'llm-saved-badge'; badge.textContent = 'Saved'; badge.hidden = true;
eye.insertAdjacentElement('afterend', badge);
eye.addEventListener('click', () => {
inp.type = inp.type === 'password' ? 'text' : 'password';
eye.classList.toggle('active', inp.type === 'text');
});
let t;
inp.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => {
if (inp.value) localStorage.setItem('llm-key-' + key, inp.value);
else localStorage.removeItem('llm-key-' + key);
badge.hidden = false;
setTimeout(() => { badge.hidden = true; }, 1800);
}, 600);
});
});
// Local service URL inputs + Connect / Disconnect
function normalizeProbeUrl(raw) {
// 0.0.0.0 is a bind address, not routable; from inside Docker use host.docker.internal
return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2');
}
async function probeUrl(rawUrl, type = '', apiKey = '') {
const url = normalizeProbeUrl(rawUrl);
const params = { url };
if (type) params.type = type;
if (apiKey) params.api_key = apiKey;
const r = await fetch('/api/probe-url?' + new URLSearchParams(params));
return r.json();
}
function applyCardState(card, key, connected, failed) {
card.classList.toggle('llm-local-card-online', connected);
card.classList.toggle('llm-local-card-offline', !connected && !!failed);
localStorage.setItem('llm-local-con-' + key, connected ? '1' : '0');
const btn = card.querySelector('.llm-local-ping');
if (!btn) return;
if (connected) {
btn.innerHTML = ' Disconnect'; btn.dataset.action = 'disconnect';
btn.className = 'llm-local-ping ok';
} else {
btn.textContent = 'Connect'; btn.dataset.action = 'connect';
btn.className = 'llm-local-ping';
}
}
document.querySelectorAll('[data-llm-local-key]').forEach(inp => {
const key = inp.dataset.llmLocalKey;
const card = inp.closest('.llm-local-card');
if (!card) return;
const btn = card.querySelector('.llm-local-ping');
const savedUrl = (_appSettings && _appSettings.engine_local_urls && _appSettings.engine_local_urls[key]) || localStorage.getItem('llm-local-url-' + key);
if (savedUrl) inp.value = savedUrl;
inp.addEventListener('input', () => { localStorage.setItem('llm-local-url-' + key, inp.value); _saveEngineLocalUrls(); });
if (localStorage.getItem('llm-local-con-' + key) === '1') applyCardState(card, key, true, false);
if (!btn) return;
btn.addEventListener('click', async () => {
const action = btn.dataset.action || 'connect';
if (action === 'disconnect') { applyCardState(card, key, false, false); return; }
const rawUrl = inp.value.trim() || inp.placeholder;
if (!rawUrl) return;
btn.disabled = true;
btn.textContent = 'Connecting…';
try {
const type = cardType(card);
const d = await probeUrl(rawUrl, type);
applyCardState(card, key, d.ok, !d.ok);
if (d.ok) {
toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success');
} else {
toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error');
}
} catch (e) {
applyCardState(card, key, false, true);
toast('Probe failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
});
// ── "Use as STT / TTS" quick-apply buttons in AI Backends section ──────────
async function applyAndSaveSettings(patch) {
try {
const resp = await fetch('/api/settings').then(r => r.json());
const updated = { ...resp, ...patch };
await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
Object.assign(_appSettings || {}, patch);
// refresh visible inputs in Settings if open
for (const [id, val] of Object.entries(patch)) {
const inp = $('s-' + id.replace(/_/g, '-'));
if (inp && inp.value !== undefined) inp.value = val;
}
await refreshTtsBackendAvailability();
await refreshSttBackends();
} catch (e) { toast('Apply failed: ' + e.message, 'error'); }
}
window.applyAndSaveSettings = applyAndSaveSettings;
window.probeUrl = probeUrl;
$('llm-use-faster-whisper-stt')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim()
|| 'http://host.docker.internal:8000';
applyAndSaveSettings({ faster_whisper_url: url });
toast('faster-whisper-server URL saved → Settings. Use the "faster-whisper" engine in the STT dropdown.', 'success');
});
$('llm-use-whisper-cpp-stt')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="whisper-cpp"]')?.value.trim()
|| 'http://host.docker.internal:8080';
applyAndSaveSettings({ whisper_cpp_url: url });
toast('whisper.cpp URL saved → Settings. Use the "whisper.cpp" engine in the STT dropdown.', 'success');
});
$('llm-use-kokoro-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="kokoro"]')?.value.trim()
|| 'http://host.docker.internal:8880/v1';
applyAndSaveSettings({ kokoro_url: url });
toast('Kokoro FastAPI URL saved → Settings. It now appears as "Kokoro FastAPI (82M)" in the TTS backend dropdown.', 'success');
});
$('llm-use-fishspeech-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="fishspeech"]')?.value.trim()
|| 'http://host.docker.internal:38080';
applyAndSaveSettings({ fishspeech_url: url });
toast('Fish-Speech URL saved → Settings. Pick "Fish-Speech (Clone + Emotion)" in Try It Out → Backend or the Rehearser.', 'success');
});
$('llm-use-vibevoice-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="vibevoice"]')?.value.trim()
|| 'http://192.168.178.8:8027';
applyAndSaveSettings({ vibevoice_url: url });
toast('VibeVoice URL saved → Settings. Pick "VibeVoice" in Try It Out → Backend or a routing rule.', 'success');
});
$('llm-use-piper-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="piper"]')?.value.trim()
|| 'localhost:10200';
applyAndSaveSettings({ tts_url: url });
toast('Piper URL saved → tts_url. Note: Piper uses Wyoming protocol — pair with an OpenAI-compatible wrapper for full support.', 'success');
});
$('llm-use-xtts-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="xtts"]')?.value.trim()
|| 'http://localhost:8024';
applyAndSaveSettings({ xtts_url: url });
toast('XTTS v2 URL saved → xtts_url. It now appears as "XTTS v2" in the TTS backend dropdown.', 'success');
});
const LLM_USE_MAP = {
'llm-use-ollama-llm': { key: 'ollama', fallback: 'http://localhost:11434/v1' },
'llm-use-vllm-llm': { key: 'vllm', fallback: 'http://localhost:8000/v1' },
'llm-use-lmstudio-llm': { key: 'lmstudio', fallback: 'http://localhost:1234/v1' },
'llm-use-llamacpp-llm': { key: 'llamacpp', fallback: 'http://localhost:8080/v1' },
'llm-use-llama-swap-llm': { key: 'llama-swap', fallback: 'http://host.docker.internal:28080/v1' },
'llm-use-litellm-llm': { key: 'litellm', fallback: 'http://host.docker.internal:14000/v1' },
};
Object.entries(LLM_USE_MAP).forEach(([id, { key, fallback }]) => {
$(id)?.addEventListener('click', () => {
const url = document.querySelector(`[data-llm-local-key="${key}"]`)?.value.trim() || fallback;
applyAndSaveSettings({ llm_url: url });
// Keep the global Active-LLM panel in sync with the card the user applied
window.syncActiveLlmPanel?.(url);
toast(`Active LLM endpoint set to ${url}. Pick a model in "Active Language Model" above.`, 'success');
});
});
// ── Active Language Model (global picker) ───────────────────────────────
(function initActiveLlmPanel() {
const urlInp = $('llm-active-url');
const modelSel = $('llm-active-model');
const refreshBtn = $('llm-active-refresh');
const statusEl = $('llm-active-status');
if (!urlInp || !modelSel) return;
function setStatus(msg, cls) {
if (!statusEl) return;
statusEl.textContent = msg || '';
statusEl.className = 'llm-active-status' + (cls ? ' ' + cls : '');
}
async function fetchModels(url, apiKey) {
url = (url || urlInp.value.trim());
if (!url) { modelSel.innerHTML = ''; setStatus(''); return; }
const want = (_appSettings && _appSettings.llm_model) || '';
setStatus('Loading models…');
if (refreshBtn) refreshBtn.disabled = true;
try {
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
const d = await fetch(fetchUrl).then(r => r.json());
if (d.error) {
let msg = 'Failed';
try {
const j = JSON.parse(d.error);
msg = j.error?.message || j.detail || d.error;
} catch(e) { msg = d.error; }
// truncate long error messages
if (msg.length > 50) msg = msg.substring(0, 47) + '...';
modelSel.innerHTML = '';
setStatus(msg, 'err');
return;
}
const models = d.models || [];
modelSel.innerHTML = models.length
? models.map(m => ``).join('')
: '';
if (want && models.includes(want)) modelSel.value = want;
setStatus(models.length ? `${models.length} model${models.length === 1 ? '' : 's'}` : 'No models found',
models.length ? 'ok' : 'err');
} catch (e) {
modelSel.innerHTML = '';
setStatus('Could not reach endpoint', 'err');
} finally {
if (refreshBtn) refreshBtn.disabled = false;
}
}
// Commit URL on blur or Enter — saves to settings and refetches models
function commitUrl() {
const url = urlInp.value.trim();
if (!url) return;
applyAndSaveSettings({ llm_url: url });
if (_appSettings) _appSettings.llm_url = url;
fetchModels(url);
}
urlInp.addEventListener('blur', commitUrl);
urlInp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); commitUrl(); } });
// Also react when user picks a datalist suggestion (fires 'change')
urlInp.addEventListener('change', commitUrl);
// Helper to refresh the dropdown options based on visible cards
function refreshEndpointOptions(currentUrl) {
if (!urlInp || urlInp.tagName !== 'SELECT') return;
let html = '';
const added = new Set();
document.querySelectorAll('.llm-local-card, .llm-card').forEach(card => {
const isLlm = card.closest('#dc-grid-llm, #llm-cloud-grid, .llm-service-grid');
if (!isLlm) return;
const uInp = card.querySelector('.dc-url-inp');
if (uInp) {
const u = uInp.value.trim() || uInp.placeholder;
const name = card.querySelector('.llm-local-name')?.textContent.trim() || 'Local Engine';
if (u && !added.has(u)) { added.add(u); html += ``; }
}
const epCode = card.querySelector('.llm-endpoint code');
if (epCode) {
const u = epCode.textContent.trim();
const name = card.querySelector('.llm-card-name')?.textContent.trim() || 'Cloud API';
if (u && !added.has(u)) { added.add(u); html += ``; }
}
});
// If the current URL isn't in the options, add it as a custom entry
const activeUrl = currentUrl || urlInp.value;
if (activeUrl && !added.has(activeUrl)) {
html += ``;
}
urlInp.innerHTML = html;
if (activeUrl) urlInp.value = activeUrl;
}
// Populate the dropdown when the user opens it
urlInp.addEventListener('focus', () => refreshEndpointOptions());
modelSel.addEventListener('change', () => {
applyAndSaveSettings({ llm_model: modelSel.value });
if (_appSettings) _appSettings.llm_model = modelSel.value;
if (modelSel.value) setStatus('Active model: ' + modelSel.value, 'ok');
});
refreshBtn?.addEventListener('click', () => fetchModels());
// Exposed so the "Use as LLM" card buttons can sync this panel
window.syncActiveLlmPanel = function (url, apiKey) {
if (typeof refreshEndpointOptions === 'function') refreshEndpointOptions(url);
urlInp.value = url || '';
fetchModels(url, apiKey);
};
// Initial state — restore saved endpoint + fetch its models
(async function () {
let url = (_appSettings && _appSettings.llm_url) || '';
if (!url) {
try {
const s = await fetch('/api/settings').then(r => r.json());
url = s.llm_url || '';
if (!_appSettings) _appSettings = s;
else { _appSettings.llm_url = s.llm_url || _appSettings.llm_url; _appSettings.llm_model = s.llm_model || _appSettings.llm_model; }
} catch (_) {}
}
// wait a tick for docker cards to render so options are available
setTimeout(() => {
if (typeof refreshEndpointOptions === 'function') refreshEndpointOptions(url);
urlInp.value = url || '';
if (url) fetchModels(url);
}, 100);
})();
})();
// ── Inline STT quick-test panel ─────────────────────────────────────────
(async function initSttTestPanel() {
const panel = $('stt-test-panel');
const sel = $('stt-test-backend');
const micBtn = $('stt-test-mic-btn');
const statusEl = $('stt-test-status');
const resultEl = $('stt-test-result');
if (!panel || !micBtn) return;
// Populate backend dropdown
async function refreshSttTestBackends() {
try {
const d = await fetch('/api/stt-backends').then(r => r.json());
const prev = sel.value;
sel.innerHTML = (d.backends || []).map(b =>
``
).join('');
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) sel.value = prev;
} catch(_) {}
}
refreshSttTestBackends();
if (!navigator.mediaDevices?.getUserMedia) {
micBtn.disabled = true;
micBtn.title = 'Microphone unavailable (requires HTTPS or localhost)';
return;
}
let mediaRecorder = null;
let chunks = [];
micBtn.addEventListener('click', async () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
return;
}
if (micBtn.classList.contains('busy')) return;
chunks = [];
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
mediaRecorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
micBtn.className = 'stt-test-mic busy';
micBtn.innerHTML = '';
statusEl.textContent = 'Transcribing…';
resultEl.style.display = 'none';
const blob = new Blob(chunks, { type: 'audio/webm' });
const form = new FormData();
form.append('file', blob, 'audio.webm');
form.append('backend', sel.value || 'configured');
try {
const r = await fetch('/api/transcribe-bytes', { method: 'POST', body: form });
const d = await r.json();
if (d.text !== undefined) {
resultEl.textContent = d.text || '(no speech detected)';
statusEl.textContent = 'Done';
} else {
resultEl.textContent = '⚠ ' + (d.detail || JSON.stringify(d));
statusEl.textContent = 'Error';
}
} catch(e) {
resultEl.textContent = '⚠ ' + e.message;
statusEl.textContent = 'Error';
}
resultEl.style.display = '';
micBtn.className = 'stt-test-mic';
micBtn.innerHTML = '';
};
mediaRecorder.start();
micBtn.className = 'stt-test-mic recording';
micBtn.innerHTML = '';
statusEl.textContent = 'Recording…';
resultEl.style.display = 'none';
} catch(e) {
statusEl.textContent = 'Mic error';
toast('Microphone error: ' + e.message, 'error');
}
});
})();
})();
// ── LLM snippet collapse ───────────────────────────────────────────────────
function initLlmSnippets(root) {
(root || document).querySelectorAll('.llm-local-snippet:not([data-snip-init])').forEach(snip => {
snip.dataset.snipInit = '1';
const bar = snip.querySelector('.llm-snippet-bar');
const pre = snip.querySelector('pre');
const copyBtn = snip.querySelector('.llm-copy-btn');
if (!bar || !pre) return;
const labelEl = bar.querySelector('span:not(.llm-snip-chev)');
const origText = labelEl ? labelEl.textContent.trim() : 'snippet';
const cardName = snip.closest('.llm-local-card')?.querySelector('.llm-local-name')?.textContent?.trim() || '';
const key = 'snip-' + (cardName + '-' + origText).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 52);
const chev = document.createElement('span');
chev.className = 'llm-snip-chev';
if (labelEl) bar.insertBefore(chev, labelEl);
else bar.prepend(chev);
const apply = (open) => {
pre.hidden = !open;
if (copyBtn) copyBtn.hidden = !open;
if (labelEl) labelEl.textContent = open ? origText : 'Show code snippet';
chev.textContent = open ? '▾' : '▸';
};
const saved = localStorage.getItem(key);
apply(saved === '1'); // default: collapsed
bar.addEventListener('click', e => {
if (copyBtn && (e.target === copyBtn || copyBtn.contains(e.target))) return;
const nowOpen = pre.hidden;
apply(nowOpen);
try { localStorage.setItem(key, nowOpen ? '1' : ''); } catch {}
});
});
}
initLlmSnippets(document);
// ── Docker management for static llm-local-cards ──────────────────────────
// Injects a unified .dc-controls-row into every static engine card that has a
// [data-llm-local-key] URL input. The row contains:
// [Connect / Disconnect] [Stop | Start | Restart] [Use as TTS/STT →]
// The Connect button is moved here from inside the URL row.
// Connecting auto-applies the URL to settings so it appears in dropdowns.
(function initStaticDockerManagement() {
document.querySelectorAll('.llm-local-card:not([data-docker-init])').forEach(card => {
const urlInp = card.querySelector('[data-llm-local-key]');
if (!urlInp) return;
card.dataset.dockerInit = '1';
const key = urlInp.dataset.llmLocalKey;
const lsKey = 'llm-docker-name-' + key;
// ── 1. Container name row (always injected, right after URL row) ──────
const urlRow = urlInp.closest('.llm-local-url');
const savedName = (_appSettings?.engine_container_names?.[key])
|| localStorage.getItem(lsKey) || '';
const nameRow = document.createElement('div');
nameRow.className = 'llm-local-url';
nameRow.innerHTML =
`` +
``;
const nameInp = nameRow.querySelector('input');
nameInp.value = savedName;
if (urlRow) urlRow.after(nameRow);
// ── 2. Unified controls row ───────────────────────────────────────────
const controlsRow = document.createElement('div');
controlsRow.className = 'dc-controls-row';
// Move the existing .llm-local-ping Connect button from URL row → controls row
const connectBtn = urlRow?.querySelector('.llm-local-ping');
if (connectBtn) {
connectBtn.innerHTML = ' Connect';
connectBtn.title = 'Test connection to this service';
urlRow.removeChild(connectBtn);
controlsRow.appendChild(connectBtn);
}
// Docker action buttons group
const dockerGroup = document.createElement('span');
dockerGroup.className = 'dc-ctrl-group';
controlsRow.appendChild(dockerGroup);
// Move the existing .llm-use-btn to controls row (gets dc-use-btn class + right-align)
const actionsEl = card.querySelector('.llm-local-actions');
const useBtnEl = actionsEl?.querySelector('.llm-use-btn');
if (useBtnEl) {
useBtnEl.classList.add('dc-use-btn');
useBtnEl.title = 'Saves this URL to Settings — makes it available in the backend dropdown. Also applied automatically when you Connect.';
const icon = document.createElement('span');
icon.className = 'mdi mdi-arrow-right-circle-outline';
useBtnEl.prepend(icon);
controlsRow.appendChild(useBtnEl);
}
if (actionsEl && actionsEl.children.length === 0) actionsEl.remove();
// Insert controls row right after the name row
nameRow.after(controlsRow);
// ── 3. Set connected / disconnected state ─────────────────────────────
function setConnected(connected) {
if (!connectBtn) return;
if (connected) {
connectBtn.innerHTML = ' Connected';
connectBtn.className = 'llm-local-ping ok';
connectBtn.dataset.action = 'disconnect';
connectBtn.title = 'Reachable — click to disconnect';
card.classList.add('llm-local-card-online');
if (useBtnEl) useBtnEl.classList.add('active');
} else {
connectBtn.innerHTML = ' Connect';
connectBtn.className = 'llm-local-ping';
connectBtn.dataset.action = 'connect';
connectBtn.title = 'Test connection to this service';
card.classList.remove('llm-local-card-online');
if (useBtnEl) useBtnEl.classList.remove('active');
}
}
if (localStorage.getItem('llm-local-con-' + key) === '1') setConnected(true);
// ── 4. Connect / Disconnect click ─────────────────────────────────────
connectBtn?.addEventListener('click', async () => {
if (connectBtn.dataset.action === 'disconnect') {
setConnected(false);
localStorage.setItem('llm-local-con-' + key, '0');
return;
}
const rawUrl = urlInp.value.trim() || urlInp.placeholder;
if (!rawUrl) return;
connectBtn.disabled = true;
connectBtn.innerHTML = ' Connecting…';
try {
const type = cardType(card);
const d = await probeUrl(rawUrl, type);
setConnected(d.ok);
localStorage.setItem('llm-local-con-' + key, d.ok ? '1' : '0');
if (d.ok) {
// Auto-apply URL to settings so it appears in dropdowns immediately
if (useBtnEl && window.applyAndSaveSettings) {
useBtnEl.click(); // triggers the existing handler which calls applyAndSaveSettings
toast(`✓ Connected and set as active ${type.toUpperCase() || 'backend'}`, 'success');
} else {
toast(`✓ ${type.toUpperCase() || 'Service'} reachable`, 'success');
}
} else {
toast('Cannot reach ' + rawUrl + ': ' + (d.error || 'No response'), 'error');
}
} catch (e) {
setConnected(false);
toast('Probe failed: ' + e.message, 'error');
} finally {
connectBtn.disabled = false;
}
});
// ── 5. Docker action buttons (Start / Stop / Restart) ─────────────────
function renderDockerBtns() {
const name = nameInp.value.trim();
if (!name) { dockerGroup.innerHTML = ''; return; }
const esc = name.replace(/&/g, '&').replace(/"/g, '"');
dockerGroup.innerHTML =
`` +
`` +
``;
dockerGroup.querySelectorAll('.dc-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.dcAction;
const cname = btn.dataset.dcName;
const orig = btn.innerHTML;
btn.disabled = true;
btn.textContent = action === 'start' ? 'Starting…' : action === 'stop' ? 'Stopping…' : 'Restarting…';
try {
const r = await fetch(`/api/local-containers/${encodeURIComponent(cname)}/${action}`, { method: 'POST' });
const d = await r.json();
if (d.ok) toast(`${cname}: ${action} OK`, 'success');
else toast(d.error || `${action} failed`, 'error');
} catch (e) { toast(`${action} failed: ${e.message}`, 'error'); }
btn.disabled = false; btn.innerHTML = orig;
});
});
}
nameInp.addEventListener('input', () => {
localStorage.setItem(lsKey, nameInp.value);
if (window._saveEngineContainerNames) window._saveEngineContainerNames();
renderDockerBtns();
});
renderDockerBtns();
});
})();
// ── Collapsible cards ──────────────────────────────────────────────────────
window.initCollapsibleCards = function initCollapsibleCards() {
const LS_KEY = 'card-collapse-v1';
const load = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; } };
const save = (k, v) => { const s = load(); s[k] = v; try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {} };
const slug = t => t.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48);
// Sync existing elements inside .card to localStorage
document.querySelectorAll('.card > details').forEach(det => {
const sum = det.querySelector('summary');
if (!sum) return;
const key = 'det-' + slug(sum.textContent);
const st = load();
if (st[key] !== undefined) det.open = st[key];
else st[key] = det.open; // persist current default
det.addEventListener('toggle', () => save(key, det.open));
});
// Add collapse toggle to every .card that has a direct h2 child
document.querySelectorAll('.card > h2').forEach(h2 => {
const card = h2.parentElement;
if (!card || card.dataset.colInit) return;
card.dataset.colInit = '1';
const key = 'card-' + slug(h2.textContent);
const open = load()[key] !== false; // default: open
// Prepend a rotating chevron inside the h2
const chev = document.createElement('span');
chev.className = 'card-chev mdi mdi-chevron-down';
chev.setAttribute('aria-hidden', 'true');
h2.prepend(chev);
h2.classList.add('card-collapse-h2');
// Wrap every element after h2 (skipping .card-subtitle which stays visible) in body
const body = document.createElement('div');
body.className = 'card-col-body';
let sib = h2.nextElementSibling;
while (sib && sib.classList.contains('card-subtitle')) sib = sib.nextElementSibling;
while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; }
card.appendChild(body);
const apply = (isOpen) => {
body.hidden = !isOpen;
card.classList.toggle('card-col-closed', !isOpen);
};
apply(open);
h2.addEventListener('click', () => {
const nowOpen = body.hidden; // hidden → about to open
apply(nowOpen);
save(key, nowOpen);
});
});
};
window.initCollapsibleCards();
// ── Collapsible integration cards (collapsed by default) ───────────────────
(function initIntegrationCards() {
const LS_KEY = 'icard-collapse-v1';
const load = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; } };
const save = (k, v) => { const s = load(); s[k] = v; try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {} };
const slug = t => t.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48);
document.querySelectorAll('.integration-card').forEach(card => {
const h3 = card.querySelector('h3');
if (!h3 || card.dataset.icardInit) return;
card.dataset.icardInit = '1';
const key = 'icard-' + slug(h3.textContent);
const open = load()[key] === true; // default: closed
// Prepend logo — favicon img or MDI icon (skip if h3 already has a leading .mdi span)
const hasIcon = h3.firstElementChild && h3.firstElementChild.classList.contains('mdi');
const faviconUrl = card.dataset.favicon;
const iconClass = card.dataset.icon;
if (!hasIcon) {
if (faviconUrl) {
const img = document.createElement('img');
img.className = 'icard-logo';
img.src = faviconUrl;
img.alt = '';
img.onerror = () => { img.style.display = 'none'; };
h3.prepend(img);
} else if (iconClass) {
const span = document.createElement('span');
span.className = 'icard-icon ' + iconClass;
h3.prepend(span);
}
} else if (faviconUrl) {
// Has existing .mdi span but also has a favicon — add favicon before the mdi span
const img = document.createElement('img');
img.className = 'icard-logo';
img.src = faviconUrl;
img.alt = '';
img.onerror = () => { img.style.display = 'none'; };
h3.prepend(img);
}
// Append chevron
const chev = document.createElement('span');
chev.className = 'icard-chev';
chev.setAttribute('aria-hidden', 'true');
h3.appendChild(chev);
// Wrap all siblings after h3 in a body div
const body = document.createElement('div');
body.className = 'icard-body';
let sib = h3.nextElementSibling;
while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; }
card.appendChild(body);
const apply = (isOpen) => {
body.hidden = !isOpen;
card.classList.toggle('icard-closed', !isOpen);
};
apply(open);
h3.addEventListener('click', () => {
const nowOpen = body.hidden;
apply(nowOpen);
save(key, nowOpen);
});
});
})();