// ── 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 ────────────────────────────────────────────────────── (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'; 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); }); }); })(); // ── 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); }); }); })();