/** * 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 `

${div.innerHTML}

`; } 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 = `
${escapeHtml(rawText)}
`; } }); } 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 = `
${escapeHtml(rawText)}
`; } // 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 = '✅ Copied!'; 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } window.renderMarkdownContent = renderMarkdownContent; window.initMarkdownViews = initMarkdownViews; window.setMarkdownContainerView = setMarkdownContainerView; window.switchResearchVersion = switchResearchVersion; window.copyMarkdownFromContainer = copyMarkdownFromContainer;