From a893817dc7df011025e2cd1f730bc458219ffc03 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Thu, 2 Apr 2026 13:03:51 +0200 Subject: [PATCH] fix: smart tooltips that flip above/below based on viewport position Replace static CSS ::after tooltips with JS-powered smart tooltips. Tooltips now detect available space above/below and flip accordingly, and clamp horizontally to viewport bounds. Hide on scroll. --- packages/dashboard/index.html | 270 ++++++++++++++++++++++++++++++++-- 1 file changed, 256 insertions(+), 14 deletions(-) diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 78f26fd..c9c5f45 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -378,25 +378,27 @@ .hype-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); } /* === TOOLTIPS === */ - .tip { position: relative; cursor: help; } - .tip::after { - content: attr(data-tip); - position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%); + /* Smart tooltip β€” positioned by JS, see initSmartTooltips() */ + .tip { cursor: help; } + #smart-tip { + position: fixed; z-index: 9999; pointer-events: none; background: var(--surface-dark); color: #e0e0e0; - border: 1px solid rgba(255,255,255,0.1); - padding: 0.6rem 0.85rem; border-radius: var(--radius-md); + border: 1px solid rgba(255,255,255,0.12); + padding: 0.55rem 0.85rem; border-radius: var(--radius-md); font-size: 0.72rem; line-height: 1.5; font-weight: 400; white-space: normal; width: max-content; max-width: 300px; - opacity: 0; pointer-events: none; transition: opacity 0.2s; - z-index: 500; box-shadow: 0 8px 32px rgba(0,0,0,0.2); + box-shadow: 0 8px 32px rgba(0,0,0,0.35); + opacity: 0; transition: opacity 0.15s; } - .tip::before { - content: ''; position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%); - border: 6px solid transparent; border-top-color: var(--surface-dark); - opacity: 0; pointer-events: none; transition: opacity 0.2s; z-index: 501; + #smart-tip.visible { opacity: 1; } + #smart-tip-arrow { + position: fixed; z-index: 9998; pointer-events: none; + width: 0; height: 0; + opacity: 0; transition: opacity 0.15s; } - .tip:hover::after, .tip:hover::before { opacity: 1; } - th.tip::after { left: 0; transform: none; } + #smart-tip-arrow.visible { opacity: 1; } + #smart-tip-arrow.up { border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--surface-dark); } + #smart-tip-arrow.down { border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--surface-dark); } /* === DETAIL PANEL === */ .panel { @@ -766,6 +768,7 @@
Finder
Blog Engine
Procurement Intelligence
+
πŸ•· Crawler Intelligence
@@ -1071,6 +1074,75 @@
+ + + @@ -1410,6 +1482,7 @@ function goToTab(tabName) { if (tabName === 'news') loadNews(); if (tabName === 'blog') loadBlogDrafts(); if (tabName === 'finder') document.getElementById('finder-switch-input').focus(); + if (tabName === 'crawlers') loadCrawlerStatus(); if (tabName === 'procurement') loadProcurement(); } @@ -3451,6 +3524,175 @@ async function loadProcLifecycle() { // INIT loadOverview(); loadChangelog(); + +// ── CRAWLER INTELLIGENCE ──────────────────────────────────────────── +async function loadCrawlerStatus() { + var status = null; + var insights = null; + try { + var r = await fetch('/api/scrapers/status', { headers: { 'Authorization': 'Bearer ' + token } }); + status = await r.json(); + } catch(e) {} + try { + var r2 = await fetch('/api/scrapers/llm-insights', { headers: { 'Authorization': 'Bearer ' + token } }); + insights = await r2.json(); + } catch(e) {} + + // DB summary cards + var db = (status && status.database) || {}; + var sc = (status && status.scrapers) || {}; + var pr = (status && status.pricing) || {}; + document.getElementById('cr-transceivers').textContent = db.transceivers != null ? db.transceivers.toLocaleString() : 'β€”'; + document.getElementById('cr-prices').textContent = pr.total_prices != null ? pr.total_prices.toLocaleString() : 'β€”'; + document.getElementById('cr-vendors').textContent = db.vendors != null ? db.vendors.toLocaleString() : 'β€”'; + document.getElementById('cr-news').textContent = db.news_articles != null ? db.news_articles.toLocaleString() : 'β€”'; + document.getElementById('cr-kb').textContent = db.knowledge_base_entries != null ? db.knowledge_base_entries.toLocaleString() : 'β€”'; + document.getElementById('cr-dbsize').textContent = db.size || 'β€”'; + document.getElementById('cr-active').textContent = sc.active != null ? sc.active + ' / ' + sc.total : 'β€”'; + document.getElementById('cr-lastprice').textContent = pr.last_update ? new Date(pr.last_update).toLocaleString('de-DE') : 'β€”'; + + // Scraper list + var list = (sc.list || []); + var categories = ['vendor','pricing','intelligence']; + var catLabel = { vendor: 'πŸͺ Vendor Scrapers', pricing: 'πŸ’Ά Pricing Scrapers', intelligence: '🧠 Intelligence Scrapers' }; + var html = ''; + for (var cat of categories) { + var items = list.filter(function(s) { return s.category === cat; }); + if (!items.length) continue; + html += '
' + catLabel[cat] + '
'; + html += '
'; + for (var s of items) { + var dot = s.status === 'active' ? '#22c55e' : '#64748b'; + var lastRun = s.lastRun ? new Date(s.lastRun).toLocaleString('de-DE') : 'Never'; + var firstSeen = s.firstSeen ? new Date(s.firstSeen).toLocaleDateString('de-DE') : 'β€”'; + html += '
' + + '
' + + '
' + + '
' + esc(s.label) + '
' + + '
' + + (s.records ? '' + s.records.toLocaleString() + ' records Β· ' : '0 records Β· ') + + 'Last: ' + lastRun + + '
' + + '
First seen: ' + firstSeen + '
' + + '
'; + } + html += '
'; + } + document.getElementById('cr-scraper-list').innerHTML = html || '
No scraper data available.
'; + + // LLM Insights β€” Hot Topics + var topics = (insights && insights.hotTopics) || []; + var topicsHtml = topics.length ? topics.map(function(t) { + return '
' + + '
' + + '
' + esc(t.title || '') + '
' + + (t.trend_score != null ? '
Score: ' + t.trend_score + '
' : '') + + '
' + + (t.summary ? '
' + esc(t.summary.substring(0,200)) + (t.summary.length > 200 ? '…' : '') + '
' : '') + + '
' + esc(t.source || '') + (t.published_at ? ' Β· ' + new Date(t.published_at).toLocaleDateString('de-DE') : '') + '
' + + '
'; + }).join('') : '
No LLM insights yet β€” run scrapers first.
'; + document.getElementById('cr-topics').innerHTML = topicsHtml; + + // Knowledge Base entries + var kb = (insights && insights.knowledgeBase) || []; + var kbHtml = kb.length ? '' + + '' + + '' + + '' + + '' + + kb.map(function(k) { + return '' + + '' + + '' + + '' + + ''; + }).join('') + + '
TitleCategoryConfidence
' + esc(k.title || '') + '' + esc(k.category || 'β€”') + '' + (k.confidence_score != null ? k.confidence_score + '%' : 'β€”') + '
' + : '
Knowledge base is empty β€” crawler LLM hasn\'t learned yet.
'; + document.getElementById('cr-kb-entries').innerHTML = kbHtml; +} + + +/* ── Smart Tooltips ─────────────────────────────────────────────────────── */ +function initSmartTooltips() { + var tip = document.createElement('div'); + tip.id = 'smart-tip'; + document.body.appendChild(tip); + + var arrow = document.createElement('div'); + arrow.id = 'smart-tip-arrow'; + document.body.appendChild(arrow); + + var hideTimer = null; + + function showTip(el) { + var text = el.getAttribute('data-tip'); + if (!text) return; + clearTimeout(hideTimer); + + tip.textContent = text; + tip.classList.remove('visible'); + arrow.classList.remove('visible', 'up', 'down'); + + // Measure after next frame so width/height are known + requestAnimationFrame(function() { + var rect = el.getBoundingClientRect(); + var tw = tip.offsetWidth; + var th = tip.offsetHeight; + var vw = window.innerWidth; + var vh = window.innerHeight; + var GAP = 10; + + // Prefer above, flip below if not enough space + var spaceAbove = rect.top; + var spaceBelow = vh - rect.bottom; + var showBelow = spaceAbove < th + GAP + 20 && spaceBelow > th + GAP + 20; + + // Horizontal: center on element, clamp to viewport + var left = rect.left + rect.width / 2 - tw / 2; + left = Math.max(8, Math.min(left, vw - tw - 8)); + + var top; + if (showBelow) { + top = rect.bottom + GAP; + arrow.style.top = (rect.bottom + 2) + 'px'; + arrow.style.left = (rect.left + rect.width / 2 - 6) + 'px'; + arrow.className = 'down visible'; + } else { + top = rect.top - th - GAP; + arrow.style.top = (rect.top - GAP + 2) + 'px'; + arrow.style.left = (rect.left + rect.width / 2 - 6) + 'px'; + arrow.className = 'up visible'; + } + + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; + tip.classList.add('visible'); + }); + } + + function hideTip() { + tip.classList.remove('visible'); + arrow.classList.remove('visible'); + } + + // Delegate: attach once to body, handle all .tip elements + document.body.addEventListener('mouseenter', function(e) { + var el = e.target.closest ? e.target.closest('[data-tip]') : null; + if (el) showTip(el); + }, true); + + document.body.addEventListener('mouseleave', function(e) { + var el = e.target.closest ? e.target.closest('[data-tip]') : null; + if (el) hideTimer = setTimeout(hideTip, 100); + }, true); + + // Hide on scroll so tooltip doesn't drift + document.addEventListener('scroll', hideTip, true); +} + +document.addEventListener('DOMContentLoaded', initSmartTooltips);