Files

742 lines
27 KiB
JavaScript

/**
* ThinkStorm Modern Frontend Client
* Interactive UI, AJAX workflows, tabs, modals, and notifications.
*/
document.addEventListener('DOMContentLoaded', () => {
initTabs();
initIntakeBox();
initMarkdownViews();
});
// ----------------- Toast Notifications -----------------
function showToast(message, type = 'info') {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerText = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(10px)';
setTimeout(() => toast.remove(), 300);
}, 3500);
}
// ----------------- Tab Navigation -----------------
function initTabs() {
const tabContainers = document.querySelectorAll('.tab-container');
tabContainers.forEach(container => {
const btns = container.querySelectorAll('.tab-btn');
const panels = container.querySelectorAll('.tab-panel');
function activateTab(rawKey) {
if (!rawKey) return;
const cleanKey = String(rawKey).replace(/^#+/, '').replace(/^tab-/, '').trim();
if (!cleanKey) return;
const targetBtn = container.querySelector(`.tab-btn[data-tab="tab-${cleanKey}"]`) ||
container.querySelector(`.tab-btn[data-tab="${cleanKey}"]`);
const targetPanel = container.querySelector(`#tab-${cleanKey}`) ||
container.querySelector(`#${cleanKey}`);
if (targetBtn && targetPanel) {
btns.forEach(b => b.classList.remove('active'));
panels.forEach(p => p.classList.remove('active'));
targetBtn.classList.add('active');
targetPanel.classList.add('active');
try {
history.replaceState(null, null, `#${cleanKey}`);
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, `tab-${cleanKey}`);
localStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, `tab-${cleanKey}`);
} catch (e) {}
}
}
btns.forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.getAttribute('data-tab');
activateTab(target);
});
});
// Check URL hash, sessionStorage, or localStorage on load
const hash = window.location.hash;
const sessionSaved = sessionStorage.getItem(`thinkstorm_active_tab_${window.location.pathname}`);
const localSaved = localStorage.getItem(`thinkstorm_active_tab_${window.location.pathname}`);
if (hash && hash.length > 1) {
activateTab(hash);
} else if (sessionSaved) {
activateTab(sessionSaved);
} else if (localSaved) {
activateTab(localSaved);
}
});
window.addEventListener('hashchange', () => {
const hash = window.location.hash;
if (hash && hash.length > 1) {
const cleanKey = hash.replace(/^#+/, '').replace(/^tab-/, '').trim();
const tabContainers = document.querySelectorAll('.tab-container');
tabContainers.forEach(c => {
const targetBtn = c.querySelector(`.tab-btn[data-tab="tab-${cleanKey}"]`) || c.querySelector(`.tab-btn[data-tab="${cleanKey}"]`);
const targetPanel = c.querySelector(`#tab-${cleanKey}`) || c.querySelector(`#${cleanKey}`);
if (targetBtn && targetPanel) {
c.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
c.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
targetBtn.classList.add('active');
targetPanel.classList.add('active');
}
});
}
});
}
// ----------------- Public Intake Box -----------------
function initIntakeBox() {
const textarea = document.getElementById('submission-text');
const counter = document.getElementById('char-count');
const urlCount = document.getElementById('detected-urls');
const form = document.getElementById('intake-form');
const fileInput = document.getElementById('submission-image');
const chooseBtn = document.getElementById('choose-image-btn');
const previewChip = document.getElementById('image-preview-chip');
const fileNameSpan = document.getElementById('image-file-name');
const removeBtn = document.getElementById('remove-image-btn');
const imageStatus = document.getElementById('image-status');
if (chooseBtn && fileInput) {
chooseBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
if (fileInput.files && fileInput.files.length > 0) {
const file = fileInput.files[0];
if (fileNameSpan) fileNameSpan.innerText = file.name;
if (previewChip) previewChip.style.display = 'inline-flex';
if (chooseBtn) chooseBtn.style.display = 'none';
if (imageStatus) imageStatus.innerText = `${(file.size / 1024).toFixed(0)} KB attached`;
}
});
}
if (removeBtn && fileInput) {
removeBtn.addEventListener('click', () => {
fileInput.value = '';
if (previewChip) previewChip.style.display = 'none';
if (chooseBtn) chooseBtn.style.display = 'inline-flex';
if (imageStatus) imageStatus.innerText = 'Max 1 image';
});
}
if (textarea) {
textarea.addEventListener('input', () => {
const len = textarea.value.length;
if (counter) counter.innerText = `${len} / 10,000`;
// Live URL detection
const urls = textarea.value.match(/https?:\/\/[^\s<>"]+/gi) || [];
if (urlCount) {
urlCount.innerText = urls.length > 0 ? `${urls.length} URL(s) detected` : 'No URLs detected';
}
});
}
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = textarea.value.trim();
if (!text) {
showToast('Please enter an idea description.', 'warning');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.innerText = 'Preserving & Ingesting...';
const file = fileInput && fileInput.files && fileInput.files.length > 0 ? fileInput.files[0] : null;
try {
let res;
if (file) {
const formData = new FormData();
formData.append('text', text);
formData.append('image', file);
res = await fetch('/api/ideas', {
method: 'POST',
body: formData
});
} else {
res = await fetch('/api/ideas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
}
let data = {};
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
try {
data = await res.json();
} catch (e) {
data = { detail: await res.text() };
}
} else {
const rawErr = await res.text();
data = { detail: rawErr || `Server error (${res.status})` };
}
if (!res.ok) {
throw new Error(data.detail || `Submission failed with status ${res.status}`);
}
showToast(`Idea preserved as ${data.id}! Ingestion started.`, 'success');
textarea.value = '';
if (fileInput) fileInput.value = '';
if (previewChip) previewChip.style.display = 'none';
if (chooseBtn) chooseBtn.style.display = 'inline-flex';
if (imageStatus) imageStatus.innerText = 'Max 1 image';
const isAuthenticated = Boolean(document.querySelector('.nav-auth-pill'));
setTimeout(() => {
if (isAuthenticated) {
window.location.href = `/ideas/${data.id}`;
} else {
submitBtn.disabled = false;
submitBtn.innerText = 'Submit Idea';
showToast(`Idea ${data.id} submitted! It will appear in the catalog once available.`, 'info');
}
}, 1000);
} catch (err) {
showToast(err.message, 'danger');
submitBtn.disabled = false;
submitBtn.innerText = 'Submit Idea';
}
});
}
}
// ----------------- Idea Actions (Claim, Release, Activate) -----------------
async function claimIdea(ideaId) {
try {
const res = await fetch(`/api/ideas/${ideaId}/claim`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Claim failed');
showToast(data.message, 'success');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
}
}
async function releaseIdea(ideaId) {
if (!confirm('Are you sure you want to release your claim on this idea?')) return;
try {
const res = await fetch(`/api/ideas/${ideaId}/release`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Release failed');
showToast(data.message, 'info');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
}
}
async function activateIdea(ideaId) {
try {
const res = await fetch(`/api/ideas/${ideaId}/activate`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Activation failed');
showToast(data.message, 'success');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
}
}
async function trashIdea(ideaId) {
console.log('[ThinkStorm] Trashing idea:', ideaId);
try {
const res = await fetch(`/api/ideas/${ideaId}/trash`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Failed to move idea to trash');
showToast(data.message || `Idea ${ideaId} moved to trash`, 'info');
setTimeout(() => {
window.location.reload();
}, 400);
} catch (err) {
console.error('[ThinkStorm] Trash error:', err);
showToast(err.message || 'Trash operation failed', 'danger');
}
}
async function restoreIdea(ideaId) {
console.log('[ThinkStorm] Restoring idea:', ideaId);
try {
const res = await fetch(`/api/ideas/${ideaId}/restore`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Failed to restore idea');
showToast(data.message || `Idea ${ideaId} restored!`, 'success');
setTimeout(() => {
window.location.reload();
}, 400);
} catch (err) {
console.error('[ThinkStorm] Restore error:', err);
showToast(err.message || 'Restore operation failed', 'danger');
}
}
async function deleteIdeaPermanently(ideaId) {
console.log('[ThinkStorm] Permanently deleting idea:', ideaId);
try {
const res = await fetch(`/api/ideas/${ideaId}/permanent`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Permanent deletion failed');
showToast(data.message || `Idea ${ideaId} deleted permanently`, 'info');
setTimeout(() => {
if (window.location.pathname.includes(`/ideas/${ideaId}`)) {
window.location.href = '/ideas?state=TRASHED';
} else {
window.location.reload();
}
}, 400);
} catch (err) {
console.error('[ThinkStorm] Delete error:', err);
showToast(err.message || 'Permanent deletion failed', 'danger');
}
}
async function emptyTrash() {
console.log('[ThinkStorm] Emptying entire trash bin');
try {
const res = await fetch('/api/trash/empty', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Failed to empty trash');
showToast(data.message || 'Trash bin emptied!', 'success');
setTimeout(() => {
window.location.reload();
}, 400);
} catch (err) {
console.error('[ThinkStorm] Empty trash error:', err);
showToast(err.message || 'Empty trash failed', 'danger');
}
}
async function createWorkTrack(ideaId) {
const workType = document.getElementById('work-type-select').value;
const name = document.getElementById('work-track-name').value.trim();
const modelOverride = document.getElementById('work-model-select')?.value || null;
if (!name) {
showToast('Please provide a track title.', 'warning');
return;
}
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-worktracks');
history.replaceState(null, null, '#worktracks');
try {
const res = await fetch(`/api/ideas/${ideaId}/work-tracks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ work_type_id: workType, name, model_override: modelOverride })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Track creation failed');
showToast(`Work Track '${data.name}' created!`, 'success');
setTimeout(() => window.location.reload(), 600);
} catch (err) {
showToast(err.message, 'danger');
}
}
async function activateWorkTrack(trackId) {
const modelSelect = document.getElementById(`model-select-${trackId}`);
const modelOverride = modelSelect ? modelSelect.value : null;
console.log(`[ThinkStorm] Activating work track ${trackId} with model:`, modelOverride || 'default');
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-worktracks');
history.replaceState(null, null, '#worktracks');
try {
const res = await fetch(`/api/ideas/work-tracks/${trackId}/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_override: modelOverride })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Track activation failed');
showToast(data.message || `Work track ${trackId} queued for generation!`, 'info');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
}
}
async function deleteWorkTrackOutput(outputId, docName, version) {
console.log(`[ThinkStorm] Deleting artifact output ID ${outputId} (${docName} v${version})`);
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-worktracks');
history.replaceState(null, null, '#worktracks');
try {
const res = await fetch(`/api/ideas/work-tracks/outputs/${outputId}`, {
method: 'DELETE'
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Artifact deletion failed');
showToast(data.message || `Artifact ${docName} (v${version}) deleted.`, 'info');
setTimeout(() => window.location.reload(), 500);
} catch (err) {
console.error('[ThinkStorm] Delete artifact error:', err);
showToast(err.message || 'Failed to delete artifact', 'danger');
}
}
async function graduateToGitea(trackId) {
if (!confirm('Graduate this Coding Project to Gitea?')) return;
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-worktracks');
history.replaceState(null, null, '#worktracks');
try {
const res = await fetch(`/api/ideas/work-tracks/${trackId}/graduate`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Graduation failed');
showToast('Graduated to Gitea successfully!', 'success');
setTimeout(() => window.location.reload(), 1000);
} catch (err) {
showToast(err.message, 'danger');
}
}
// ----------------- Admin Helpers -----------------
async function testService(serviceId) {
const btn = document.getElementById(`test-btn-${serviceId}`);
if (btn) btn.innerText = 'Testing...';
try {
const res = await fetch(`/api/admin/services/${serviceId}/test`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Service test failed');
showToast(`[${serviceId.toUpperCase()}] ${data.message}`, data.healthy ? 'success' : 'danger');
setTimeout(() => window.location.reload(), 1000);
} catch (err) {
showToast(err.message, 'danger');
if (btn) btn.innerText = 'Test';
}
}
async function retryJob(ideaId) {
try {
const res = await fetch(`/api/admin/jobs/retry/${ideaId}`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Retry failed');
showToast(data.message, 'info');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
}
}
async function quarantineDecision(ideaId, decision) {
try {
const res = await fetch(`/api/admin/quarantine/${ideaId}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Decision failed');
showToast(data.message, 'success');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
}
}
async function syncIdeaToOpenGist(ideaId) {
const btn = document.getElementById('sync-opengist-btn');
if (btn) btn.innerText = 'Publishing...';
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-artifacts');
history.replaceState(null, null, '#artifacts');
try {
const res = await fetch(`/api/ideas/${ideaId}/sync-opengist`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Sync to OpenGist failed');
showToast(data.message, 'success');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
if (btn) btn.innerText = '🔄 Publish / Re-sync to OpenGist';
}
}
async function syncIdeaToGitea(ideaId) {
const btn = document.getElementById('sync-gitea-btn');
const originalLabel = btn ? btn.innerText : '';
if (btn) btn.innerText = 'Publishing to Gitea...';
sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-artifacts');
history.replaceState(null, null, '#artifacts');
try {
const res = await fetch(`/api/ideas/${ideaId}/sync-gitea`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Sync to Gitea failed');
showToast(data.message, 'success');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'danger');
if (btn) btn.innerText = originalLabel;
}
}
async function reprocessIdea(ideaId) {
try {
showToast('Enqueuing idea for complete intake & research processing...', 'info');
const res = await fetch(`/api/ideas/${ideaId}/reprocess`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bypass_duplicate_check: true })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Reprocess request failed');
showToast('Pipeline started! Page will refresh shortly...', 'success');
setTimeout(() => window.location.reload(), 2000);
} catch (err) {
showToast(err.message, 'danger');
}
}
// ----------------- Universal Markdown Renderer & View Toggler -----------------
function renderMarkdownContent(rawText) {
if (!rawText) return '';
if (typeof marked !== 'undefined') {
try {
marked.setOptions({
gfm: true,
breaks: true,
pedantic: false
});
const parsed = marked.parse(rawText);
if (typeof DOMPurify !== 'undefined' && typeof DOMPurify.sanitize === 'function') {
return DOMPurify.sanitize(parsed);
}
return parsed;
} catch (e) {
console.warn('[ThinkStorm] marked.parse error, falling back:', e);
}
}
// Fallback simple escape
const div = document.createElement('div');
div.textContent = rawText;
return `<p style="white-space:pre-wrap;">${div.innerHTML}</p>`;
}
function initMarkdownViews(root = document) {
const containers = root.querySelectorAll('.markdown-view-container, [data-markdown-view]');
containers.forEach(container => {
if (container.dataset.initialized === 'true') return;
container.dataset.initialized = 'true';
// Check if this container has multi-version panes
const versionPanes = container.querySelectorAll('.research-version-pane');
if (versionPanes.length > 0) {
versionPanes.forEach(pane => {
const formattedBox = pane.querySelector('.markdown-formatted');
const codeBox = pane.querySelector('.markdown-code');
const rawSourceEl = pane.querySelector('.raw-markdown-source');
const rawText = rawSourceEl ? (rawSourceEl.value || rawSourceEl.textContent || '') : '';
if (formattedBox && rawText) {
formattedBox.innerHTML = renderMarkdownContent(rawText);
}
if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) {
codeBox.innerHTML = `<pre><code>${escapeHtml(rawText)}</code></pre>`;
}
});
} else {
const formattedBox = container.querySelector('.markdown-formatted');
const codeBox = container.querySelector('.markdown-code');
const rawSourceEl = container.querySelector('.raw-markdown-source');
let rawText = '';
if (rawSourceEl) {
rawText = rawSourceEl.value || rawSourceEl.textContent || '';
} else if (codeBox && codeBox.querySelector('code')) {
rawText = codeBox.querySelector('code').textContent || '';
} else if (container.dataset.content) {
rawText = container.dataset.content;
}
// Render formatted markdown HTML
if (formattedBox && rawText) {
formattedBox.innerHTML = renderMarkdownContent(rawText);
}
// Ensure codeBox has raw text
if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) {
codeBox.innerHTML = `<pre><code>${escapeHtml(rawText)}</code></pre>`;
}
// Default to Formatted view
if (formattedBox) formattedBox.style.display = 'block';
if (codeBox) codeBox.style.display = 'none';
}
// Setup toggle buttons
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
toggleBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const mode = btn.getAttribute('data-view');
setMarkdownContainerView(container, mode);
});
});
});
// Also auto-render any standalone .markdown-body-auto
const standaloneBodies = root.querySelectorAll('.markdown-body-auto');
standaloneBodies.forEach(el => {
if (el.dataset.initialized === 'true') return;
el.dataset.initialized = 'true';
const rawText = el.textContent || '';
if (rawText.trim()) {
el.innerHTML = renderMarkdownContent(rawText);
}
});
}
function setMarkdownContainerView(container, mode) {
const toggleBtns = container.querySelectorAll('.btn-toggle-view');
toggleBtns.forEach(b => {
if (b.getAttribute('data-view') === mode) {
b.classList.add('active');
} else {
b.classList.remove('active');
}
});
const versionPanes = container.querySelectorAll('.research-version-pane');
if (versionPanes.length > 0) {
versionPanes.forEach(pane => {
const formattedBox = pane.querySelector('.markdown-formatted');
const codeBox = pane.querySelector('.markdown-code');
if (mode === 'code') {
if (formattedBox) formattedBox.style.display = 'none';
if (codeBox) codeBox.style.display = 'block';
} else {
if (formattedBox) formattedBox.style.display = 'block';
if (codeBox) codeBox.style.display = 'none';
}
});
} else {
const formattedBox = container.querySelector('.markdown-formatted');
const codeBox = container.querySelector('.markdown-code');
if (mode === 'code') {
if (formattedBox) formattedBox.style.display = 'none';
if (codeBox) codeBox.style.display = 'block';
} else {
if (formattedBox) formattedBox.style.display = 'block';
if (codeBox) codeBox.style.display = 'none';
}
}
}
function switchResearchVersion(selectEl, stage) {
const selectedVersion = String(selectEl.value);
const container = selectEl.closest('.tab-panel') || document.getElementById(`tab-${stage.toLowerCase().replace('_', '-')}`);
if (!container) return;
// Toggle panes for this stage
const panes = container.querySelectorAll(`.research-version-pane[data-stage="${stage}"]`);
let activePane = null;
panes.forEach(pane => {
if (String(pane.dataset.version) === selectedVersion) {
pane.style.display = 'block';
activePane = pane;
} else {
pane.style.display = 'none';
}
});
// Update header meta badges
if (activePane) {
const metaContainer = container.querySelector('.markdown-view-meta');
if (metaContainer) {
const modelBadge = metaContainer.querySelector('.research-meta-model');
if (modelBadge && activePane.dataset.model) {
modelBadge.innerText = `🤖 ${activePane.dataset.model}`;
}
const tokenBadge = metaContainer.querySelector('.research-meta-tokens');
if (tokenBadge && activePane.dataset.tokens) {
tokenBadge.innerText = `⚡ ${activePane.dataset.tokens} tokens`;
}
const timeBadge = metaContainer.querySelector('.research-meta-time');
if (timeBadge && activePane.dataset.time) {
timeBadge.innerText = `🕒 ${activePane.dataset.time}`;
}
}
}
}
async function copyMarkdownFromContainer(btn) {
const container = btn.closest('.markdown-view-container');
if (!container) return;
// Find visible pane if multi-version
let targetRoot = container;
const visiblePane = container.querySelector('.research-version-pane:not([style*="display:none"]):not([style*="display: none"])');
if (visiblePane) {
targetRoot = visiblePane;
}
const rawSourceEl = targetRoot.querySelector('.raw-markdown-source');
const codeEl = targetRoot.querySelector('.markdown-code code');
const text = rawSourceEl ? (rawSourceEl.value || rawSourceEl.textContent) : (codeEl ? codeEl.textContent : '');
if (!text) {
showToast('No markdown content to copy', 'warning');
return;
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
const origHtml = btn.innerHTML;
btn.innerHTML = '<span>✅ Copied!</span>';
showToast('Markdown copied to clipboard!', 'success');
setTimeout(() => {
btn.innerHTML = origHtml;
}, 2000);
} catch (err) {
console.error('Copy failed:', err);
showToast('Failed to copy to clipboard', 'danger');
}
}
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
window.renderMarkdownContent = renderMarkdownContent;
window.initMarkdownViews = initMarkdownViews;
window.setMarkdownContainerView = setMarkdownContainerView;
window.switchResearchVersion = switchResearchVersion;
window.copyMarkdownFromContainer = copyMarkdownFromContainer;