Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> | |
| <meta http-equiv="Pragma" content="no-cache" /> | |
| <meta http-equiv="Expires" content="0" /> | |
| <title>🚀 Crypto Intelligence Hub - Advanced Dashboard</title> | |
| <!-- Google Fonts - Modern & Professional --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Manrope:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet"> | |
| <!-- Core Design System --> | |
| <link rel="stylesheet" href="static/css/design-tokens.css" /> | |
| <link rel="stylesheet" href="static/css/glassmorphism.css" /> | |
| <link rel="stylesheet" href="static/css/design-system.css" /> | |
| <link rel="stylesheet" href="static/css/dashboard.css" /> | |
| <link rel="stylesheet" href="static/css/pro-dashboard.css" /> | |
| <link rel="stylesheet" href="static/css/sentiment-modern.css" /> | |
| <!-- Optional Enhanced Features (Loaded conditionally) --> | |
| <link rel="stylesheet" href="static/css/mobile-responsive.css" media="screen" /> | |
| <link rel="stylesheet" href="static/css/toast.css" /> | |
| <link rel="stylesheet" href="static/css/accessibility.css" /> | |
| <link rel="stylesheet" href="static/css/navigation.css" /> | |
| <link rel="stylesheet" href="static/css/connection-status.css" /> | |
| <!-- Chart.js --> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" defer></script> | |
| <!-- SVG status icon tweaks --> | |
| <style> | |
| .status-pill .status-icon { | |
| margin-inline: 0.35rem; | |
| flex-shrink: 0; | |
| } | |
| .status-pill .status-label { | |
| white-space: nowrap; | |
| } | |
| </style> | |
| </head> | |
| <body data-theme="dark" id="main-body"> | |
| <!-- ===== تنظیم بکاند (اسکریپت) ===== --> | |
| <script> | |
| // تنظیم خودکار: اگر روی localhost هستیم از localhost استفاده کن، وگرنه از HF Space | |
| if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { | |
| window.BACKEND_URL = `http://${window.location.hostname}:7860`; | |
| } else { | |
| // برای HuggingFace Spaces | |
| window.BACKEND_URL = 'https://really-amin-datasourceforcryptocurrency.hf.space'; | |
| } | |
| </script> | |
| <div class="app-shell"> | |
| <!-- Sidebar Navigation --> | |
| <aside class="sidebar"> | |
| <div class="brand"> | |
| <div class="brand-icon"> | |
| <svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(96, 165, 250, 0.15)"/> | |
| <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <circle cx="12" cy="12" r="3" fill="currentColor" opacity="0.8"/> | |
| <path d="M8 12h8M12 8v8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/> | |
| </svg> | |
| </div> | |
| <div class="brand-text"> | |
| <strong>Crypto Intelligence Hub</strong> | |
| <span class="env-pill"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" fill="rgba(129, 140, 248, 0.2)"/> | |
| <path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| AI Powered | |
| </span> | |
| </div> | |
| </div> | |
| <nav class="nav"> | |
| <button class="nav-button active" data-nav="page-overview"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <rect x="3" y="3" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.2"/><rect x="14" y="3" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.2"/> | |
| <rect x="3" y="14" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.2"/><rect x="14" y="14" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.2"/> | |
| </svg> | |
| Overview | |
| </button> | |
| <button class="nav-button" data-nav="page-market"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <line x1="12" y1="2" x2="12" y2="22" stroke-width="3"/> | |
| <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" stroke-width="2.5"/> | |
| </svg> | |
| Market | |
| </button> | |
| <button class="nav-button" data-nav="page-chart"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M3 3v18h18" stroke-width="3"/> | |
| <path d="M7 12l4-4 4 4 6-6v8H7z" stroke-width="2.5"/> | |
| </svg> | |
| Chart Lab | |
| </button> | |
| <button class="nav-button" data-nav="page-ai"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" stroke-width="2.5"/> | |
| <circle cx="12" cy="12" r="3.5" fill="currentColor" opacity="0.3"/> | |
| </svg> | |
| AI Advisor | |
| </button> | |
| <button class="nav-button" data-nav="page-news"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" stroke-width="2.5"/> | |
| <path d="M18 14h-8M15 10h-5M19 18h-3" stroke-width="2.5"/> | |
| </svg> | |
| News | |
| </button> | |
| <button class="nav-button" data-nav="page-providers"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 2L2 7l10 5 10-5-10-5z" fill="currentColor" opacity="0.2"/><path d="M2 17l10 5 10-5" fill="currentColor" opacity="0.2"/><path d="M2 12l10 5 10-5" stroke-width="2.5"/> | |
| </svg> | |
| Providers | |
| </button> | |
| <button class="nav-button" data-nav="page-datasets"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <rect x="3" y="3" width="18" height="18" rx="2" ry="2" fill="currentColor" opacity="0.1"/> | |
| <line x1="9" y1="3" x2="9" y2="21" stroke-width="3"/> | |
| <line x1="3" y1="9" x2="21" y2="9" stroke-width="3"/> | |
| </svg> | |
| Datasets & Models | |
| </button> | |
| <button class="nav-button" data-nav="page-api"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M4 9h16M4 15h16" stroke-width="3"/> | |
| <path d="M12 3v18" stroke-width="3"/> | |
| <circle cx="12" cy="12" r="2.5" fill="currentColor" opacity="0.3"/> | |
| </svg> | |
| API Explorer | |
| </button> | |
| <button class="nav-button" data-nav="page-debug"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.1"/> | |
| <line x1="12" y1="8" x2="12" y2="12" stroke-width="3"/> | |
| <line x1="12" y1="16" x2="12.01" y2="16" stroke-width="3"/> | |
| </svg> | |
| Diagnostics | |
| </button> | |
| <button class="nav-button" data-nav="page-settings"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="3.5" fill="currentColor" opacity="0.2"/> | |
| <path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24" stroke-width="2.5"/> | |
| </svg> | |
| Settings | |
| </button> | |
| </nav> | |
| <div class="sidebar-footer"> | |
| <div class="footer-badge"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg> | |
| <span>AI Powered</span> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- Main Content Area --> | |
| <main class="main-area"> | |
| <!-- Top Bar with Status --> | |
| <header class="topbar"> | |
| <div class="topbar-content"> | |
| <div class="topbar-icon"> | |
| <svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(96, 165, 250, 0.1)"/> | |
| <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <circle cx="12" cy="12" r="3" fill="currentColor" opacity="0.8"/> | |
| <path d="M8 12h8M12 8v8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/> | |
| </svg> | |
| </div> | |
| <div class="topbar-text"> | |
| <h1> | |
| <span class="title-gradient">Crypto Intelligence</span> | |
| <span class="title-accent">Dashboard</span> | |
| </h1> | |
| <p class="text-muted"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display: inline-block; vertical-align: middle; margin-right: 6px;"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/> | |
| <path d="M12 8v4l3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| Live market data, AI-powered sentiment analysis, and comprehensive crypto intelligence | |
| </p> | |
| </div> | |
| </div> | |
| <div class="status-group"> | |
| <!-- API Health with SVG icon --> | |
| <div class="status-pill" data-api-health data-state="warn"> | |
| <span class="status-dot"></span> | |
| <svg class="status-icon" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"> | |
| <path d="M4 7h16M4 12h10M4 17h7" | |
| stroke="currentColor" | |
| stroke-width="2" | |
| stroke-linecap="round" | |
| stroke-linejoin="round" /> | |
| </svg> | |
| <span class="status-label">checking</span> | |
| </div> | |
| <!-- WebSocket Status with SVG icon --> | |
| <div class="status-pill" data-ws-status data-state="warn"> | |
| <span class="status-dot"></span> | |
| <svg class="status-icon" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"> | |
| <path d="M4 5h16v6H4z" | |
| stroke="currentColor" | |
| stroke-width="2" | |
| fill="none" | |
| stroke-linejoin="round" /> | |
| <path d="M8 15h8M10 19h4" | |
| stroke="currentColor" | |
| stroke-width="2" | |
| stroke-linecap="round" /> | |
| </svg> | |
| <span class="status-label">connecting</span> | |
| </div> | |
| </div> | |
| </header> | |
| <div class="page-container"> | |
| <!-- ========== OVERVIEW PAGE ========== --> | |
| <section id="page-overview" class="page active"> | |
| <div class="section-header"> | |
| <h2 class="section-title">📊 Market Overview</h2> | |
| <div style="display: flex; gap: 12px; align-items: center;"> | |
| <span class="chip" style="background: var(--success-glow); color: var(--success-light);">Live Data</span> | |
| <span class="chip" style="background: var(--primary-glow); color: var(--primary-light);">Real-time</span> | |
| </div> | |
| </div> | |
| <div class="grid-four" data-overview-stats></div> | |
| <div class="glass-card" style="margin-top:1.5rem;"> | |
| <div class="card-header"> | |
| <h4>Market Overview - 24H</h4> | |
| <button class="btn-secondary btn-sm" id="refresh-market-btn" onclick="refreshAllData()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none"> | |
| <path d="M1 4v6h6M23 20v-6h-6" stroke="currentColor" stroke-width="2"/> | |
| <path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| <span id="refresh-text">Refresh</span> | |
| </button> | |
| </div> | |
| <div style="height: 400px; padding: 20px;"> | |
| <canvas id="market-overview-chart"></canvas> | |
| </div> | |
| </div> | |
| <div class="glass-card" style="margin-top:1.5rem;"> | |
| <div class="card-header"> | |
| <h4>Top Cryptocurrencies</h4> | |
| </div> | |
| <div class="table-container"> | |
| <table class="table"> | |
| <thead> | |
| <tr> | |
| <th>#</th> | |
| <th>Coin</th> | |
| <th>Price</th> | |
| <th>24h %</th> | |
| <th>7d %</th> | |
| <th>Market Cap</th> | |
| <th>Volume</th> | |
| <th>Chart</th> | |
| </tr> | |
| </thead> | |
| <tbody data-top-coins-body></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="glass-card" style="margin-top:1.5rem;"> | |
| <h4>Global Sentiment</h4> | |
| <canvas id="sentiment-chart" height="200"></canvas> | |
| </div> | |
| </section> | |
| <!-- ========== MARKET PAGE ========== --> | |
| <section id="page-market" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">💹 Market Explorer</h2> | |
| <div style="display: flex; gap: 12px; align-items: center;"> | |
| <span class="chip" style="background: var(--primary-glow); color: var(--primary-light);">50+ Coins</span> | |
| <span class="chip" style="background: var(--secondary-glow); color: var(--secondary-light);">24/7 Updates</span> | |
| </div> | |
| </div> | |
| <div class="search-bar"> | |
| <input type="text" placeholder="Search coins..." data-market-search id="market-search" name="market-search" /> | |
| <div class="button-group"> | |
| <button class="secondary active" data-timeframe="24h">24h</button> | |
| <button class="secondary" data-timeframe="7d">7d</button> | |
| <button class="secondary" data-timeframe="30d">30d</button> | |
| </div> | |
| <label class="input-chip">Live Updates | |
| <div class="toggle"> | |
| <input type="checkbox" data-live-toggle id="live-toggle" name="live-toggle" /> | |
| <span></span> | |
| </div> | |
| </label> | |
| </div> | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>#</th> | |
| <th>Symbol</th> | |
| <th>Name</th> | |
| <th>Price</th> | |
| <th>24h %</th> | |
| <th>Volume</th> | |
| <th>Market Cap</th> | |
| </tr> | |
| </thead> | |
| <tbody data-market-body></tbody> | |
| </table> | |
| </div> | |
| <aside class="drawer" data-market-drawer> | |
| <header> | |
| <h3 data-drawer-symbol></h3> | |
| <button data-close-drawer>×</button> | |
| </header> | |
| <div class="drawer-body"> | |
| <div data-drawer-stats></div> | |
| <div data-chart-wrapper style="margin:1rem 0;"> | |
| <canvas id="market-detail-chart" height="180"></canvas> | |
| </div> | |
| <h4>Related Headlines</h4> | |
| <div data-drawer-news></div> | |
| </div> | |
| </aside> | |
| </section> | |
| <!-- ========== CHART LAB PAGE ========== --> | |
| <section id="page-chart" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">📈 Chart Lab</h2> | |
| <div style="display: flex; gap: 12px; align-items: center;"> | |
| <span class="chip" style="background: var(--primary-glow); color: var(--primary-light);">TradingView Style</span> | |
| <span class="chip" style="background: var(--secondary-glow); color: var(--secondary-light);">Professional</span> | |
| </div> | |
| </div> | |
| <div class="glass-card"> | |
| <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 20px;"> | |
| <div> | |
| <label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--text-normal);">Select Cryptocurrency</label> | |
| <div style="position: relative;"> | |
| <input | |
| type="text" | |
| id="chartCoinSearch" | |
| placeholder="Search Bitcoin, Ethereum..." | |
| style="width: 100%; padding: 12px 40px 12px 16px; background: rgba(15, 23, 42, 0.6); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 10px; color: white; font-size: 14px;" | |
| autocomplete="off" | |
| /> | |
| <svg style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; color: #94A3B8;" width="16" height="16" viewBox="0 0 24 24" fill="none"> | |
| <path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| <div id="chartCoinDropdown" style="display: none; position: absolute; top: calc(100% + 8px); left: 0; right: 0; max-height: 300px; overflow-y: auto; background: rgba(17, 24, 39, 0.95); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 12px; backdrop-filter: blur(20px); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); z-index: 1000;"></div> | |
| </div> | |
| </div> | |
| <div> | |
| <label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--text-normal);">Timeframe</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <button class="secondary" data-chart-timeframe="1">1D</button> | |
| <button class="secondary active" data-chart-timeframe="7">7D</button> | |
| <button class="secondary" data-chart-timeframe="30">30D</button> | |
| <button class="secondary" data-chart-timeframe="90">90D</button> | |
| <button class="secondary" data-chart-timeframe="365">1Y</button> | |
| </div> | |
| </div> | |
| <div> | |
| <label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--text-normal);">Chart Type</label> | |
| <select id="chartType" style="width: 100%; padding: 12px 16px; background: rgba(15, 23, 42, 0.6); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 10px; color: white; font-size: 14px;"> | |
| <option value="line">Line Chart</option> | |
| <option value="area">Area Chart</option> | |
| <option value="bar">Bar Chart</option> | |
| </select> | |
| </div> | |
| </div> | |
| <button class="primary" onclick="loadSelectedChart()" style="width: 100%;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none"> | |
| <path d="M3 3v18h18" stroke="currentColor" stroke-width="2"/> | |
| <path d="M7 10l4-4 4 4 6-6" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| Load Chart | |
| </button> | |
| </div> | |
| <div class="glass-card" style="margin-top: 20px;"> | |
| <div class="card-header"> | |
| <h4 id="selectedCoinTitle">Select a coin to view chart</h4> | |
| <div style="display: flex; gap: 8px;"> | |
| <span class="badge badge-cyan" id="selectedCoinPrice">$0</span> | |
| <span class="badge badge-success" id="selectedCoinChange">0%</span> | |
| </div> | |
| </div> | |
| <div style="height: 500px; padding: 20px;"> | |
| <canvas id="price-chart"></canvas> | |
| </div> | |
| </div> | |
| <div class="glass-card" style="margin-top: 20px;"> | |
| <div class="card-header"> | |
| <h4>Volume Analysis</h4> | |
| </div> | |
| <div style="height: 300px; padding: 20px;"> | |
| <canvas id="volume-chart"></canvas> | |
| </div> | |
| </div> | |
| <div class="grid-two" style="margin-top: 20px;"> | |
| <div class="glass-card"> | |
| <div class="card-header"> | |
| <h4>RSI Indicator</h4> | |
| </div> | |
| <div style="height: 250px; padding: 20px;"> | |
| <canvas id="rsi-chart"></canvas> | |
| </div> | |
| </div> | |
| <div class="glass-card"> | |
| <div class="card-header"> | |
| <h4>Moving Averages</h4> | |
| </div> | |
| <div style="height: 250px; padding: 20px;"> | |
| <canvas id="ma-chart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ========== AI ADVISOR PAGE ========== --> | |
| <section id="page-ai" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">🤖 AI Advisor & Sentiment Analysis</h2> | |
| <div style="display: flex; gap: 12px; align-items: center;"> | |
| <span class="chip" style="background: var(--primary-glow); color: var(--primary-light);">HF Models</span> | |
| <span class="chip" style="background: var(--success-glow); color: var(--success-light);">Ensemble AI</span> | |
| </div> | |
| </div> | |
| <div class="grid-two" style="margin-bottom: 24px;"> | |
| <div class="glass-card"> | |
| <div class="card-header"> | |
| <h4>💬 Natural Language Query</h4> | |
| </div> | |
| <form data-query-form style="display: flex; flex-direction: column; gap: 16px;"> | |
| <label style="display: flex; flex-direction: column; gap: 8px;"> | |
| <span style="font-weight: 600; color: var(--text-secondary);">Ask anything about crypto markets</span> | |
| <input type="text" placeholder="e.g., What is the current Bitcoin price? What are the top 5 coins by market cap?" name="query" style="padding: 14px 18px; border-radius: 12px; background: var(--glass-bg-light); border: 1px solid var(--glass-border); color: var(--text-primary); font-size: 0.9375rem;" /> | |
| </label> | |
| <button class="btn-secondary" type="submit" style="padding: 14px 24px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 8px;"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/> | |
| </svg> | |
| Ask AI | |
| </button> | |
| </form> | |
| <div data-query-output style="margin-top: 20px; padding: 20px; background: var(--glass-bg-light); border-radius: 12px; border: 1px solid var(--glass-border); min-height: 60px; color: var(--text-secondary);"></div> | |
| </div> | |
| <div class="glass-card"> | |
| <div class="card-header"> | |
| <h4>📊 Sentiment Analyzer</h4> | |
| <span class="chip" style="font-size: 0.75rem;">Ensemble AI Models</span> | |
| </div> | |
| <form data-sentiment-form style="display: flex; flex-direction: column; gap: 16px;"> | |
| <label style="display: flex; flex-direction: column; gap: 8px;"> | |
| <span style="font-weight: 600; color: var(--text-secondary);">Enter text for sentiment analysis</span> | |
| <textarea name="text" rows="5" placeholder="e.g., Bitcoin is showing strong bullish momentum with increasing adoption..." style="padding: 14px 18px; border-radius: 12px; background: var(--glass-bg-light); border: 1px solid var(--glass-border); color: var(--text-primary); font-size: 0.9375rem; resize: vertical; font-family: inherit;"></textarea> | |
| </label> | |
| <button class="btn-secondary" type="submit" style="padding: 14px 24px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 8px;"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> | |
| </svg> | |
| Analyze Sentiment | |
| </button> | |
| </form> | |
| </div> | |
| </div> | |
| <!-- Sentiment Results Display --> | |
| <div id="sentiment-results" class="glass-card" style="display: none; margin-top: 24px;"> | |
| <div class="card-header"> | |
| <h4>📈 Analysis Results</h4> | |
| </div> | |
| <div data-sentiment-output style="padding: 24px;"></div> | |
| </div> | |
| </section> | |
| <!-- ========== NEWS PAGE ========== --> | |
| <section id="page-news" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">📰 News Feed</h2> | |
| <div style="display: flex; gap: 12px; align-items: center;"> | |
| <span class="chip" id="news-count">Loading...</span> | |
| <button class="btn-secondary" id="refresh-news" style="padding: 8px 16px; font-size: 0.875rem;"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="margin-right: 6px;"> | |
| <path d="M1 4v6h6M23 20v-6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| Refresh | |
| </button> | |
| </div> | |
| </div> | |
| <div class="glass-card" style="margin-bottom: 24px;"> | |
| <div class="search-bar" style="display: flex; gap: 12px; flex-wrap: wrap;"> | |
| <input type="text" placeholder="🔍 Search headlines..." data-news-search id="news-search" name="news-search" style="flex: 1; min-width: 200px;" /> | |
| <select data-news-range style="padding: 10px 14px; border-radius: 10px; background: var(--glass-bg); border: 1px solid var(--glass-border); color: var(--text-secondary);"> | |
| <option value="24h">Last 24h</option> | |
| <option value="7d">Last 7d</option> | |
| <option value="30d">Last 30d</option> | |
| <option value="all">All Time</option> | |
| </select> | |
| <input type="text" placeholder="Symbol (BTC, ETH...)" data-news-symbol id="news-symbol" name="news-symbol" style="width: 150px;" /> | |
| </div> | |
| </div> | |
| <div id="news-loading" style="text-align: center; padding: 60px; color: var(--text-muted); display: none;"> | |
| <div style="font-size: 3rem; margin-bottom: 16px;">📰</div> | |
| <div>Loading news...</div> | |
| </div> | |
| <div id="news-error" style="display: none; padding: 40px; text-align: center; color: var(--danger);"></div> | |
| <div id="news-grid" data-news-container style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 20px;"></div> | |
| <div class="modal-backdrop" data-news-modal style="display: none;"> | |
| <div class="modal glass-card" data-news-modal-content style="max-width: 800px; max-height: 90vh; overflow-y: auto;"></div> | |
| <button data-close-news-modal style="position: absolute; top: 20px; right: 20px; background: var(--glass-bg-strong); border: 1px solid var(--glass-border); color: var(--text-primary); width: 40px; height: 40px; border-radius: 50%; font-size: 24px; cursor: pointer; display: flex; align-items: center; justify-content: center;">×</button> | |
| </div> | |
| </section> | |
| <!-- ========== PROVIDERS PAGE ========== --> | |
| <section id="page-providers" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">API Providers</h2> | |
| <span class="chip">Multi-source</span> | |
| </div> | |
| <div data-providers-grid class="grid-three"></div> | |
| </section> | |
| <!-- ========== DATASETS & MODELS PAGE ========== --> | |
| <section id="page-datasets" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">Datasets & Models</h2> | |
| <span class="chip">14+ datasets</span> | |
| </div> | |
| <div class="glass-card"> | |
| <h4>Datasets</h4> | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Name</th> | |
| <th>Type</th> | |
| <th>Updated</th> | |
| <th>Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody data-datasets-body></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="glass-card" style="margin-top:1.5rem;"> | |
| <h4>HF Models</h4> | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Name</th> | |
| <th>Task</th> | |
| <th>Status</th> | |
| <th>Description</th> | |
| </tr> | |
| </thead> | |
| <tbody data-models-body></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="glass-card" style="margin-top:1.5rem;"> | |
| <h4>Test Model</h4> | |
| <form data-model-test-form> | |
| <div class="grid-two"> | |
| <label>Model | |
| <select name="model" data-model-select></select> | |
| </label> | |
| <label>Input Text | |
| <textarea name="input" rows="3" placeholder="Enter text to test the model..."></textarea> | |
| </label> | |
| </div> | |
| <button class="primary" type="submit">Run Test</button> | |
| </form> | |
| <div data-model-test-output style="margin-top:1rem;"></div> | |
| </div> | |
| <div class="modal-backdrop" data-dataset-modal> | |
| <div class="modal" data-dataset-modal-content></div> | |
| <button data-close-dataset-modal>×</button> | |
| </div> | |
| </section> | |
| <!-- ========== API EXPLORER PAGE ========== --> | |
| <section id="page-api" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">API Explorer</h2> | |
| <span class="chip">15+ endpoints</span> | |
| </div> | |
| <div class="glass-card"> | |
| <h4>Test Endpoint</h4> | |
| <form data-api-form> | |
| <div class="grid-two"> | |
| <label>Endpoint | |
| <select data-endpoint-select> | |
| <option value="0">/api/health</option> | |
| </select> | |
| </label> | |
| <label>Method | |
| <select data-method-select> | |
| <option value="GET">GET</option> | |
| <option value="POST">POST</option> | |
| </select> | |
| </label> | |
| </div> | |
| <div data-api-description style="margin:0.5rem 0;font-size:0.875rem;color:var(--text-secondary);"></div> | |
| <div data-api-path style="margin:0.5rem 0;font-family:monospace;font-size:0.875rem;"></div> | |
| <label>Body (JSON) | |
| <textarea data-body-input rows="4"></textarea> | |
| </label> | |
| <button class="primary" type="submit">Send Request</button> | |
| </form> | |
| <div data-api-response style="margin-top:1rem;"></div> | |
| </div> | |
| </section> | |
| <!-- ========== DIAGNOSTICS PAGE ========== --> | |
| <section id="page-debug" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">System Diagnostics</h2> | |
| </div> | |
| <div class="grid-two"> | |
| <div class="glass-card"> | |
| <h4>Health Status</h4> | |
| <div data-health-info>Checking...</div> | |
| </div> | |
| <div class="glass-card"> | |
| <h4>WebSocket Status</h4> | |
| <div data-ws-info>Checking...</div> | |
| </div> | |
| </div> | |
| <div class="grid-two" style="margin-top:1.5rem;"> | |
| <div class="glass-card"> | |
| <h4>Request Logs</h4> | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Time</th> | |
| <th>Method</th> | |
| <th>Endpoint</th> | |
| <th>Status</th> | |
| <th>Duration</th> | |
| </tr> | |
| </thead> | |
| <tbody data-request-log></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="glass-card"> | |
| <h4>Error Logs</h4> | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Time</th> | |
| <th>Endpoint</th> | |
| <th>Message</th> | |
| </tr> | |
| </thead> | |
| <tbody data-error-log></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="glass-card" style="margin-top:1.5rem;"> | |
| <h4>WebSocket Events</h4> | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Time</th> | |
| <th>Type</th> | |
| <th>Details</th> | |
| </tr> | |
| </thead> | |
| <tbody data-ws-log></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <button class="secondary" data-refresh-health style="margin-top:1.5rem;">Refresh</button> | |
| </section> | |
| <!-- ========== SETTINGS PAGE ========== --> | |
| <section id="page-settings" class="page"> | |
| <div class="section-header"> | |
| <h2 class="section-title">Settings</h2> | |
| </div> | |
| <div class="glass-card"> | |
| <h4>Display Settings</h4> | |
| <div class="grid-two"> | |
| <label class="input-chip">Theme | |
| <div class="toggle"> | |
| <input type="checkbox" data-theme-toggle id="theme-toggle" name="theme-toggle" /> | |
| <span></span> | |
| </div> | |
| <span style="font-size: 0.875rem; color: var(--text-muted); margin-left: 8px;">Light / Dark</span> | |
| </label> | |
| <label class="input-chip">Compact Layout | |
| <div class="toggle"> | |
| <input type="checkbox" data-layout-toggle id="layout-toggle" name="layout-toggle" /> | |
| <span></span> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="glass-card" style="margin-top:1.5rem;"> | |
| <h4>Refresh Intervals</h4> | |
| <div class="grid-two"> | |
| <label>Market Data (seconds) | |
| <input type="number" min="10" step="5" value="30" data-market-interval id="market-interval" name="market-interval" /> | |
| </label> | |
| <label>News Feed (seconds) | |
| <input type="number" min="30" step="10" value="60" data-news-interval id="news-interval" name="news-interval" /> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="inline-message inline-info" style="margin-top:1.5rem;"> | |
| Settings are stored locally in your browser. | |
| </div> | |
| </section> | |
| </div> | |
| </main> | |
| </div> | |
| <!-- Enhanced Chart Functionality --> | |
| <script> | |
| let chartInstances = {}; | |
| let allCoins = []; | |
| let selectedCoin = null; | |
| let selectedTimeframe = 7; | |
| // Initialize Navigation | |
| function initNavigation() { | |
| const navButtons = document.querySelectorAll('.nav-button'); | |
| const pages = document.querySelectorAll('.page'); | |
| const topbarIcon = document.querySelector('.topbar-icon'); | |
| // Page icon mapping | |
| const pageIcons = { | |
| 'page-overview': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(96, 165, 250, 0.1)"/> | |
| <rect x="3" y="3" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.3"/><rect x="14" y="3" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.3"/> | |
| <rect x="3" y="14" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.3"/><rect x="14" y="14" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.3"/> | |
| </svg>`, | |
| 'page-market': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(167, 139, 250, 0.1)"/> | |
| <path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/> | |
| </svg>`, | |
| 'page-chart': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(244, 114, 182, 0.1)"/> | |
| <path d="M3 3v18h18" stroke="currentColor" stroke-width="2.5"/><path d="M7 12l4-4 4 4 6-6v8H7z" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg>`, | |
| 'page-ai': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(52, 211, 153, 0.1)"/> | |
| <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <circle cx="12" cy="12" r="3.5" fill="currentColor" opacity="0.4"/> | |
| </svg>`, | |
| 'page-news': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(251, 191, 36, 0.1)"/> | |
| <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/> | |
| <path d="M18 14h-8M15 10h-5M19 18h-3" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/> | |
| </svg>`, | |
| 'page-providers': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(34, 211, 238, 0.1)"/> | |
| <path d="M12 2L2 7l10 5 10-5-10-5z" fill="currentColor" opacity="0.3"/><path d="M2 17l10 5 10-5" fill="currentColor" opacity="0.3"/><path d="M2 12l10 5 10-5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/> | |
| </svg>`, | |
| 'page-datasets': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(192, 132, 252, 0.1)"/> | |
| <rect x="3" y="3" width="18" height="18" rx="2" ry="2" fill="currentColor" opacity="0.15"/> | |
| <line x1="9" y1="3" x2="9" y2="21" stroke="currentColor" stroke-width="2.5"/><line x1="3" y1="9" x2="21" y2="9" stroke="currentColor" stroke-width="2.5"/> | |
| </svg>`, | |
| 'page-api': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(129, 140, 248, 0.1)"/> | |
| <path d="M4 9h16M4 15h16" stroke="currentColor" stroke-width="2.5"/><path d="M12 3v18" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="12" r="2.5" fill="currentColor" opacity="0.4"/> | |
| </svg>`, | |
| 'page-debug': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(248, 113, 113, 0.1)"/> | |
| <circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.15"/> | |
| <line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/><line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/> | |
| </svg>`, | |
| 'page-settings': `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="rgba(148, 163, 184, 0.1)"/> | |
| <circle cx="12" cy="12" r="3.5" fill="currentColor" opacity="0.25"/> | |
| <path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg>` | |
| }; | |
| function updateHeaderIcon(pageId) { | |
| if (topbarIcon && pageIcons[pageId]) { | |
| topbarIcon.innerHTML = pageIcons[pageId]; | |
| } | |
| } | |
| function loadPageData(pageId) { | |
| // Load data based on page | |
| switch(pageId) { | |
| case 'page-overview': | |
| loadMarketOverviewChart().catch(e => console.error('[Chart]', e)); | |
| loadTopCoinsWithSparklines().catch(e => console.error('[Top Coins]', e)); | |
| loadOverviewStats().catch(e => console.error('[Stats]', e)); | |
| break; | |
| case 'page-news': | |
| loadNewsData().catch(e => console.error('[News]', e)); | |
| break; | |
| case 'page-market': | |
| // Load market data if needed | |
| break; | |
| case 'page-chart': | |
| // Load chart lab data if needed | |
| break; | |
| } | |
| } | |
| navButtons.forEach(button => { | |
| button.addEventListener('click', () => { | |
| const targetPage = button.dataset.nav; | |
| // Update active states | |
| navButtons.forEach(btn => btn.classList.remove('active')); | |
| button.classList.add('active'); | |
| // Show target page | |
| pages.forEach(page => { | |
| page.classList.toggle('active', page.id === targetPage); | |
| }); | |
| // Update header icon | |
| updateHeaderIcon(targetPage); | |
| // Load page data | |
| loadPageData(targetPage); | |
| // Scroll to top | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| }); | |
| }); | |
| // Set initial header icon | |
| const activeButton = document.querySelector('.nav-button.active'); | |
| if (activeButton) { | |
| updateHeaderIcon(activeButton.dataset.nav); | |
| } | |
| } | |
| // Theme Toggle | |
| function initThemeToggle() { | |
| const themeToggle = document.querySelector('[data-theme-toggle]'); | |
| const body = document.getElementById('main-body'); | |
| if (!themeToggle || !body) return; | |
| // Load saved theme | |
| const savedTheme = localStorage.getItem('theme') || 'dark'; | |
| body.setAttribute('data-theme', savedTheme); | |
| themeToggle.checked = savedTheme === 'light'; | |
| // Toggle theme | |
| themeToggle.addEventListener('change', (e) => { | |
| const newTheme = e.target.checked ? 'light' : 'dark'; | |
| body.setAttribute('data-theme', newTheme); | |
| localStorage.setItem('theme', newTheme); | |
| }); | |
| } | |
| // Initialize | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initNavigation(); | |
| initThemeToggle(); | |
| // Load data with error handling | |
| // Load all data with better error handling | |
| Promise.all([ | |
| loadMarketOverviewChart().catch(e => { | |
| console.error('[Overview Chart]', e); | |
| const ctx = document.getElementById('market-overview-chart'); | |
| if (ctx && ctx.parentElement) { | |
| ctx.parentElement.innerHTML = `<div style="padding: 40px; text-align: center; color: var(--text-muted);">Failed to load chart: ${e.message}</div>`; | |
| } | |
| }), | |
| loadTopCoinsWithSparklines().catch(e => { | |
| console.error('[Top Coins]', e); | |
| const tbody = document.querySelector('[data-top-coins-body]'); | |
| if (tbody) { | |
| tbody.innerHTML = `<tr><td colspan="6" style="text-align: center; padding: 40px; color: var(--text-muted);">Failed to load: ${e.message}</td></tr>`; | |
| } | |
| }), | |
| loadOverviewStats().catch(e => { | |
| console.error('[Stats]', e); | |
| const statsContainer = document.querySelector('[data-overview-stats]'); | |
| if (statsContainer) { | |
| statsContainer.innerHTML = '<div style="grid-column: 1/-1; padding: 20px; text-align: center; color: var(--text-muted);">Failed to load stats. Please refresh the page.</div>'; | |
| } | |
| }), | |
| loadNewsData().catch(e => { | |
| console.error('[News]', e); | |
| const newsGrid = document.getElementById('news-grid'); | |
| if (newsGrid) { | |
| newsGrid.innerHTML = '<div style="grid-column: 1/-1; padding: 40px; text-align: center; color: var(--text-muted);">Failed to load news. Please refresh the page.</div>'; | |
| } | |
| }) | |
| ]).then(() => { | |
| console.log('[Init] All initial data loaded'); | |
| }); | |
| initChartLabControls(); | |
| }); | |
| // Refresh all data without page reload | |
| async function refreshAllData() { | |
| const refreshBtn = document.getElementById('refresh-market-btn'); | |
| const refreshText = document.getElementById('refresh-text'); | |
| if (refreshBtn) { | |
| refreshBtn.disabled = true; | |
| refreshBtn.style.opacity = '0.6'; | |
| refreshBtn.style.cursor = 'wait'; | |
| } | |
| if (refreshText) { | |
| refreshText.textContent = 'Refreshing...'; | |
| } | |
| try { | |
| // Refresh all data sources in parallel | |
| await Promise.all([ | |
| loadOverviewStats().catch(e => console.error('[Stats]', e)), | |
| loadMarketOverviewChart().catch(e => console.error('[Chart]', e)), | |
| loadTopCoinsWithSparklines().catch(e => console.error('[Top Coins]', e)) | |
| ]); | |
| console.log('[Refresh] All data refreshed successfully'); | |
| // Show success toast if available | |
| if (window.toast) { | |
| window.toast.show('Data refreshed successfully', 'success', { duration: 2000 }); | |
| } | |
| } catch (error) { | |
| console.error('[Refresh] Error refreshing data:', error); | |
| // Show error toast if available | |
| if (window.toast) { | |
| window.toast.show(`Refresh failed: ${error.message}`, 'error', { duration: 4000 }); | |
| } | |
| } finally { | |
| if (refreshBtn) { | |
| refreshBtn.disabled = false; | |
| refreshBtn.style.opacity = '1'; | |
| refreshBtn.style.cursor = 'pointer'; | |
| } | |
| if (refreshText) { | |
| refreshText.textContent = 'Refresh'; | |
| } | |
| } | |
| } | |
| // Load Overview Stats | |
| async function loadOverviewStats() { | |
| try { | |
| // Try primary backend, fallback to alternative ports/paths | |
| let backendUrl = window.BACKEND_URL || window.location.origin; | |
| const statsContainer = document.querySelector('[data-overview-stats]'); | |
| if (!statsContainer) return; | |
| let res = await fetch(`${backendUrl}/api/market/stats`); | |
| // If 404, try alternative endpoints or ports | |
| if (!res.ok && res.status === 404) { | |
| // Try port 7861 (FastAPI might be on different port) | |
| const altBackend = backendUrl.includes(':7860') ? backendUrl.replace(':7860', ':7861') : `${backendUrl}:7861`; | |
| try { | |
| res = await fetch(`${altBackend}/api/market/stats`); | |
| if (res.ok) backendUrl = altBackend; | |
| } catch (e) { | |
| // Try same origin without port | |
| res = await fetch(`${window.location.origin}/api/market/stats`); | |
| if (res.ok) backendUrl = window.location.origin; | |
| } | |
| } | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}: ${res.statusText}`); | |
| } | |
| const data = await res.json(); | |
| const stats = data.stats || data.data || data || {}; | |
| statsContainer.innerHTML = ` | |
| <div class="stat-card"> | |
| <div class="stat-icon" style="background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(34, 211, 238, 0.15));"> | |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/> | |
| </svg> | |
| </div> | |
| <div class="stat-label">Total Market Cap</div> | |
| <div class="stat-value">$${formatNum(stats.total_market_cap || 0)}</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon" style="background: linear-gradient(135deg, rgba(52, 211, 153, 0.2), rgba(34, 211, 238, 0.15));"> | |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <path d="M3 3v18h18"/><path d="M7 12l4-4 4 4 6-6v8H7z"/> | |
| </svg> | |
| </div> | |
| <div class="stat-label">24h Volume</div> | |
| <div class="stat-value">$${formatNum(stats.total_volume_24h || 0)}</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon" style="background: linear-gradient(135deg, rgba(251, 191, 36, 0.2), rgba(244, 114, 182, 0.15));"> | |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/> | |
| </svg> | |
| </div> | |
| <div class="stat-label">BTC Dominance</div> | |
| <div class="stat-value">${(stats.btc_dominance || 0).toFixed(2)}%</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon" style="background: linear-gradient(135deg, rgba(167, 139, 250, 0.2), rgba(129, 140, 248, 0.15));"> | |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/> | |
| <rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/> | |
| </svg> | |
| </div> | |
| <div class="stat-label">Active Coins</div> | |
| <div class="stat-value">${stats.active_cryptocurrencies || 0}</div> | |
| </div> | |
| `; | |
| } catch (e) { | |
| console.error('[Stats] Error:', e); | |
| const statsContainer = document.querySelector('[data-overview-stats]'); | |
| if (statsContainer) { | |
| statsContainer.innerHTML = '<div style="grid-column: 1/-1; padding: 20px; text-align: center; color: var(--text-muted);">Failed to load stats</div>'; | |
| } | |
| } | |
| } | |
| // Market Overview Chart | |
| async function loadMarketOverviewChart() { | |
| try { | |
| let backendUrl = window.BACKEND_URL || window.location.origin; | |
| console.log('[Chart] Loading from:', backendUrl); | |
| let res = await fetch(`${backendUrl}/api/coins/top?limit=10`); | |
| // If 404, try alternative endpoints | |
| if (!res.ok && res.status === 404) { | |
| const altBackend = backendUrl.includes(':7860') ? backendUrl.replace(':7860', ':7861') : `${backendUrl}:7861`; | |
| try { | |
| res = await fetch(`${altBackend}/api/coins/top?limit=10`); | |
| if (res.ok) backendUrl = altBackend; | |
| } catch (e) { | |
| res = await fetch(`${window.location.origin}/api/coins/top?limit=10`); | |
| if (res.ok) backendUrl = window.location.origin; | |
| } | |
| } | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}: ${res.statusText}`); | |
| } | |
| const data = await res.json(); | |
| const coins = data.coins || data.data || data || []; | |
| if (!coins || coins.length === 0) { | |
| console.warn('[Chart] No coins data received'); | |
| const ctx = document.getElementById('market-overview-chart'); | |
| if (ctx && ctx.parentElement) { | |
| ctx.parentElement.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">No data available</div>'; | |
| } | |
| return; | |
| } | |
| console.log('[Chart] Loaded', coins.length, 'coins'); | |
| const ctx = document.getElementById('market-overview-chart'); | |
| if (!ctx) return; | |
| const colors = ['#60A5FA', '#22D3EE', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#F472B6', '#FB923C', '#2DD4BF', '#818CF8']; | |
| const datasets = coins.slice(0, 10).map((coin, i) => { | |
| // Use sparkline data if available, otherwise generate sample data | |
| const priceData = coin.sparkline_in_7d?.price || | |
| coin.sparkline_data || | |
| Array.from({length: 168}, () => coin.price || coin.current_price || 0); | |
| return { | |
| label: coin.name || coin.symbol, | |
| data: priceData, | |
| borderColor: colors[i], | |
| backgroundColor: colors[i] + '15', | |
| borderWidth: 2.5, | |
| fill: false, | |
| tension: 0.3, | |
| pointRadius: 0, | |
| pointHoverRadius: 4, | |
| pointHoverBorderWidth: 2 | |
| }; | |
| }); | |
| if (chartInstances.overview) chartInstances.overview.destroy(); | |
| // TradingView-style overview chart | |
| chartInstances.overview = new Chart(ctx, { | |
| type: 'line', | |
| data: { labels: Array.from({length: 168}, (_, i) => i), datasets }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| interaction: { mode: 'index', intersect: false }, | |
| plugins: { | |
| legend: { | |
| display: true, | |
| position: 'top', | |
| align: 'start', | |
| labels: { | |
| usePointStyle: true, | |
| pointStyle: 'circle', | |
| padding: 16, | |
| font: { | |
| size: 12, | |
| weight: '600', | |
| family: 'Manrope' | |
| }, | |
| color: '#E2E8F0', | |
| boxWidth: 14, | |
| boxHeight: 14, | |
| generateLabels: function(chart) { | |
| const original = Chart.defaults.plugins.legend.labels.generateLabels; | |
| const labels = original.call(this, chart); | |
| labels.forEach(label => { | |
| label.fillStyle = label.strokeStyle; | |
| label.strokeStyle = label.strokeStyle; | |
| label.lineWidth = 2; | |
| }); | |
| return labels; | |
| } | |
| }, | |
| title: { | |
| display: false | |
| } | |
| }, | |
| tooltip: { | |
| backgroundColor: 'rgba(10, 15, 30, 0.98)', | |
| padding: 12, | |
| borderColor: 'rgba(96, 165, 250, 0.3)', | |
| borderWidth: 1.5, | |
| titleColor: '#F8FAFC', | |
| bodyColor: '#E2E8F0', | |
| titleFont: { size: 12, weight: '700', family: 'Manrope' }, | |
| bodyFont: { size: 11, family: 'Manrope' }, | |
| cornerRadius: 8, | |
| displayColors: true, | |
| boxPadding: 6 | |
| } | |
| }, | |
| scales: { | |
| x: { | |
| grid: { | |
| display: true, | |
| color: 'rgba(255, 255, 255, 0.03)', | |
| lineWidth: 1 | |
| }, | |
| ticks: { | |
| display: false, | |
| color: '#64748B', | |
| font: { size: 10, family: 'Manrope' } | |
| }, | |
| border: { | |
| display: false | |
| } | |
| }, | |
| y: { | |
| position: 'right', | |
| grid: { | |
| color: 'rgba(255, 255, 255, 0.05)', | |
| lineWidth: 1, | |
| drawBorder: false, | |
| drawTicks: true | |
| }, | |
| ticks: { | |
| color: '#94A3B8', | |
| font: { size: 11, weight: '600', family: 'Manrope' }, | |
| padding: 8, | |
| stepSize: null, | |
| precision: 2, | |
| callback: function(value) { | |
| if (value >= 1e12) return '$' + (value / 1e12).toFixed(2) + 'T'; | |
| if (value >= 1e9) return '$' + (value / 1e9).toFixed(2) + 'B'; | |
| if (value >= 1e6) return '$' + (value / 1e6).toFixed(2) + 'M'; | |
| if (value >= 1e3) return '$' + (value / 1e3).toFixed(2) + 'K'; | |
| return '$' + value.toFixed(2); | |
| } | |
| }, | |
| border: { | |
| display: false | |
| }, | |
| beginAtZero: false | |
| } | |
| }, | |
| elements: { | |
| line: { | |
| tension: 0, | |
| borderWidth: 2 | |
| }, | |
| point: { | |
| radius: 0, | |
| hoverRadius: 4 | |
| } | |
| } | |
| } | |
| }); | |
| } catch (e) { | |
| console.error('[Chart] Error loading market overview:', e); | |
| const ctx = document.getElementById('market-overview-chart'); | |
| if (ctx) { | |
| ctx.parentElement.innerHTML = `<div style="padding: 40px; text-align: center; color: var(--text-muted);"> | |
| <p>Failed to load chart data</p> | |
| <p style="font-size: 0.875rem; margin-top: 8px;">${e.message}</p> | |
| </div>`; | |
| } | |
| } | |
| } | |
| // Top Coins with Sparklines | |
| async function loadTopCoinsWithSparklines() { | |
| try { | |
| let backendUrl = window.BACKEND_URL || window.location.origin; | |
| console.log('[Top Coins] Loading from:', backendUrl); | |
| let res = await fetch(`${backendUrl}/api/coins/top?limit=20`); | |
| // If 404, try alternative endpoints | |
| if (!res.ok && res.status === 404) { | |
| const altBackend = backendUrl.includes(':7860') ? backendUrl.replace(':7860', ':7861') : `${backendUrl}:7861`; | |
| try { | |
| res = await fetch(`${altBackend}/api/coins/top?limit=20`); | |
| if (res.ok) backendUrl = altBackend; | |
| } catch (e) { | |
| res = await fetch(`${window.location.origin}/api/coins/top?limit=20`); | |
| if (res.ok) backendUrl = window.location.origin; | |
| } | |
| } | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}: ${res.statusText}`); | |
| } | |
| const data = await res.json(); | |
| const coins = data.coins || data || []; | |
| if (!coins || coins.length === 0) { | |
| console.warn('No coins data received'); | |
| const tbody = document.querySelector('[data-top-coins-body]'); | |
| if (tbody) { | |
| tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; padding: 40px; color: var(--text-muted);">No data available</td></tr>'; | |
| } | |
| return; | |
| } | |
| const tbody = document.querySelector('[data-top-coins-body]'); | |
| if (!tbody) return; | |
| tbody.innerHTML = coins.map((coin, i) => { | |
| const change24h = coin.price_change_percentage_24h || 0; | |
| const change7d = coin.price_change_percentage_7d_in_currency || 0; | |
| const coinName = coin.name || 'Unknown'; | |
| const coinSymbol = (coin.symbol || 'N/A').toUpperCase(); | |
| const coinImage = coin.image || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIxNiIgZmlsbD0iIzM0NDc1NiIvPjwvc3ZnPg=='; | |
| const coinId = coin.id || `coin-${i}`; | |
| return ` | |
| <tr> | |
| <td>${i + 1}</td> | |
| <td> | |
| <div style="display: flex; align-items: center; gap: 12px;"> | |
| <img src="${coinImage}" style="width: 32px; height: 32px; border-radius: 50%;" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIxNiIgZmlsbD0iIzM0NDc1NiIvPjwvc3ZnPg=='"> | |
| <div> | |
| <div style="font-weight: 600;">${coinName}</div> | |
| <div style="font-size: 11px; color: #94A3B8;">${coinSymbol}</div> | |
| </div> | |
| </div> | |
| </td> | |
| <td style="font-weight: 600;">$${formatNum(coin.current_price)}</td> | |
| <td> | |
| <span style="color: ${change24h >= 0 ? '#34D399' : '#F87171'}; font-weight: 600;"> | |
| ${change24h >= 0 ? '↑' : '↓'} ${Math.abs(Number(change24h) || 0).toFixed(2)}% | |
| </span> | |
| </td> | |
| <td> | |
| <span style="color: ${change7d >= 0 ? '#34D399' : '#F87171'}; font-weight: 600;"> | |
| ${change7d >= 0 ? '↑' : '↓'} ${Math.abs(Number(change7d) || 0).toFixed(2)}% | |
| </span> | |
| </td> | |
| <td>$${formatNum(coin.market_cap)}</td> | |
| <td>$${formatNum(coin.total_volume)}</td> | |
| <td><canvas id="spark-${coinId}" width="100" height="30"></canvas></td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| setTimeout(() => { | |
| coins.forEach((coin, i) => { | |
| const coinId = coin.id || `coin-${i}`; | |
| if (coin.sparkline_in_7d?.price && Array.isArray(coin.sparkline_in_7d.price)) { | |
| const coinChange24h = coin.price_change_percentage_24h || 0; | |
| createSparkline(`spark-${coinId}`, coin.sparkline_in_7d.price, coinChange24h >= 0); | |
| } | |
| }); | |
| }, 100); | |
| } catch (e) { | |
| console.error('[Table] Error loading top coins:', e); | |
| const tbody = document.querySelector('[data-top-coins-body]'); | |
| if (tbody) { | |
| tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 40px; color: var(--text-muted);"> | |
| <p>Failed to load data</p> | |
| <p style="font-size: 0.875rem; margin-top: 8px;">${e.message}</p> | |
| </td></tr>`; | |
| } | |
| } | |
| } | |
| // Sparkline | |
| function createSparkline(id, data, isPositive) { | |
| const canvas = document.getElementById(id); | |
| if (!canvas) return; | |
| const color = isPositive ? '#10B981' : '#EF4444'; | |
| new Chart(canvas, { | |
| type: 'line', | |
| data: { | |
| labels: data.map((_, i) => i), | |
| datasets: [{ | |
| data: data, | |
| borderColor: color, | |
| backgroundColor: color + '30', | |
| borderWidth: 2, | |
| fill: true, | |
| tension: 0.4, | |
| pointRadius: 0 | |
| }] | |
| }, | |
| options: { | |
| responsive: false, | |
| plugins: { legend: { display: false }, tooltip: { enabled: false } }, | |
| scales: { x: { display: false }, y: { display: false } } | |
| } | |
| }); | |
| } | |
| // Chart Lab Controls | |
| function initChartLabControls() { | |
| const input = document.getElementById('chartCoinSearch'); | |
| const dropdown = document.getElementById('chartCoinDropdown'); | |
| if (!input || !dropdown) return; | |
| input.addEventListener('focus', async () => { | |
| if (allCoins.length === 0) { | |
| let backendUrl = window.BACKEND_URL || window.location.origin; | |
| let res = await fetch(`${backendUrl}/api/coins/top?limit=100`); | |
| // If 404, try alternative endpoints | |
| if (!res.ok && res.status === 404) { | |
| const altBackend = backendUrl.includes(':7860') ? backendUrl.replace(':7860', ':7861') : `${backendUrl}:7861`; | |
| try { | |
| res = await fetch(`${altBackend}/api/coins/top?limit=100`); | |
| if (res.ok) backendUrl = altBackend; | |
| } catch (e) { | |
| res = await fetch(`${window.location.origin}/api/coins/top?limit=100`); | |
| if (res.ok) backendUrl = window.location.origin; | |
| } | |
| } | |
| if (res.ok) { | |
| const data = await res.json(); | |
| allCoins = data.coins || data || []; | |
| } | |
| } | |
| renderCoinDropdown(allCoins); | |
| dropdown.style.display = 'block'; | |
| }); | |
| input.addEventListener('input', (e) => { | |
| const term = e.target.value.toLowerCase(); | |
| const filtered = allCoins.filter(c => c.name.toLowerCase().includes(term) || c.symbol.toLowerCase().includes(term)); | |
| renderCoinDropdown(filtered); | |
| }); | |
| document.addEventListener('click', (e) => { | |
| if (!input.contains(e.target) && !dropdown.contains(e.target)) { | |
| dropdown.style.display = 'none'; | |
| } | |
| }); | |
| // Timeframe buttons | |
| document.querySelectorAll('[data-chart-timeframe]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('[data-chart-timeframe]').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| selectedTimeframe = parseInt(btn.dataset.chartTimeframe); | |
| if (selectedCoin) loadCoinDetailChart(selectedCoin.id); | |
| }); | |
| }); | |
| } | |
| function renderCoinDropdown(coins) { | |
| const dropdown = document.getElementById('chartCoinDropdown'); | |
| if (!dropdown) return; | |
| dropdown.innerHTML = coins.slice(0, 50).map(coin => ` | |
| <div onclick="selectChartCoin('${coin.id}')" style="padding: 12px 16px; display: flex; align-items: center; gap: 12px; cursor: pointer; border-bottom: 1px solid rgba(255, 255, 255, 0.05); transition: all 0.2s;"> | |
| <img src="${coin.image}" style="width: 32px; height: 32px; border-radius: 50%;"> | |
| <div style="flex: 1;"> | |
| <div style="font-weight: 600;">${coin.name}</div> | |
| <div style="font-size: 11px; color: #94A3B8;">${coin.symbol.toUpperCase()}</div> | |
| </div> | |
| <div style="font-weight: 600;">$${formatNum(coin.current_price)}</div> | |
| </div> | |
| `).join(''); | |
| dropdown.querySelectorAll('div[onclick]').forEach(el => { | |
| el.addEventListener('mouseenter', () => { | |
| el.style.background = 'rgba(34, 211, 238, 0.15)'; | |
| el.style.borderLeft = '3px solid #22D3EE'; | |
| }); | |
| el.addEventListener('mouseleave', () => { | |
| el.style.background = 'transparent'; | |
| el.style.borderLeft = 'none'; | |
| }); | |
| }); | |
| } | |
| window.selectChartCoin = function(coinId) { | |
| selectedCoin = allCoins.find(c => c.id === coinId); | |
| if (!selectedCoin) return; | |
| document.getElementById('chartCoinSearch').value = `${selectedCoin.name} (${selectedCoin.symbol.toUpperCase()})`; | |
| document.getElementById('chartCoinDropdown').style.display = 'none'; | |
| loadCoinDetailChart(coinId); | |
| }; | |
| window.loadSelectedChart = function() { | |
| if (selectedCoin) { | |
| loadCoinDetailChart(selectedCoin.id); | |
| } | |
| }; | |
| async function loadCoinDetailChart(coinId) { | |
| try { | |
| const backendUrl = window.BACKEND_URL || window.location.origin; | |
| const interval = selectedTimeframe === 1 ? '1h' : selectedTimeframe === 7 ? '1d' : selectedTimeframe === 30 ? '1d' : '1d'; | |
| const res = await fetch(`${backendUrl}/api/charts/price/${coinId}?interval=${interval}&limit=100`); | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}: ${res.statusText}`); | |
| } | |
| const chartData = await res.json(); | |
| // Transform data to match expected format | |
| let data; | |
| if (chartData.prices && Array.isArray(chartData.prices)) { | |
| data = { | |
| prices: chartData.prices.map((p, i) => { | |
| const timestamp = typeof p === 'number' ? Date.now() - (chartData.prices.length - i) * 3600000 : p[0]; | |
| const price = typeof p === 'number' ? p : p[1]; | |
| return [timestamp, price]; | |
| }), | |
| total_volumes: chartData.volumes?.map((v, i) => { | |
| const timestamp = typeof v === 'number' ? Date.now() - (chartData.volumes.length - i) * 3600000 : v[0]; | |
| const volume = typeof v === 'number' ? v : v[1]; | |
| return [timestamp, volume]; | |
| }) || [] | |
| }; | |
| } else { | |
| // Fallback: use OHLCV endpoint | |
| const ohlcvRes = await fetch(`${backendUrl}/api/ohlcv?symbol=${coinId}&interval=${interval}&limit=100`); | |
| if (ohlcvRes.ok) { | |
| const ohlcvData = await ohlcvRes.json(); | |
| data = { | |
| prices: ohlcvData.data?.map((d, i) => [Date.now() - (ohlcvData.data.length - i) * 3600000, d.close || d.price || 0]) || [], | |
| total_volumes: ohlcvData.data?.map((d, i) => [Date.now() - (ohlcvData.data.length - i) * 3600000, d.volume || 0]) || [] | |
| }; | |
| } else { | |
| throw new Error('No chart data available'); | |
| } | |
| } | |
| const coin = allCoins.find(c => c.id === coinId) || selectedCoin; | |
| document.getElementById('selectedCoinTitle').textContent = `${coin.name} (${coin.symbol.toUpperCase()})`; | |
| document.getElementById('selectedCoinPrice').textContent = `$${formatNum(coin.current_price)}`; | |
| const change = coin.price_change_percentage_24h || 0; | |
| const changeEl = document.getElementById('selectedCoinChange'); | |
| changeEl.textContent = `${change >= 0 ? '+' : ''}${change.toFixed(2)}%`; | |
| changeEl.className = `badge ${change >= 0 ? 'badge-success' : 'badge-danger'}`; | |
| // Price Chart | |
| const priceCtx = document.getElementById('price-chart'); | |
| if (priceCtx) { | |
| if (chartInstances.price) chartInstances.price.destroy(); | |
| const chartType = document.getElementById('chartType').value; | |
| // TradingView-style chart configuration | |
| chartInstances.price = new Chart(priceCtx, { | |
| type: chartType === 'bar' ? 'bar' : 'line', | |
| data: { | |
| labels: data.prices.map(p => new Date(p[0])), | |
| datasets: [{ | |
| label: 'Price', | |
| data: data.prices.map(p => p[1]), | |
| borderColor: '#60A5FA', | |
| backgroundColor: chartType === 'area' ? 'rgba(96, 165, 250, 0.1)' : chartType === 'bar' ? 'rgba(96, 165, 250, 0.6)' : 'transparent', | |
| borderWidth: chartType === 'line' ? 2 : 0, | |
| fill: chartType === 'area', | |
| tension: 0, | |
| pointRadius: 0, | |
| pointHoverRadius: 4, | |
| pointHoverBorderWidth: 2, | |
| pointHoverBackgroundColor: '#60A5FA' | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| interaction: { | |
| intersect: false, | |
| mode: 'index' | |
| }, | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { | |
| backgroundColor: 'rgba(10, 15, 30, 0.98)', | |
| padding: 14, | |
| borderColor: 'rgba(96, 165, 250, 0.3)', | |
| borderWidth: 1.5, | |
| titleColor: '#F8FAFC', | |
| bodyColor: '#E2E8F0', | |
| titleFont: { size: 13, weight: '700', family: 'Manrope' }, | |
| bodyFont: { size: 12, family: 'Manrope' }, | |
| cornerRadius: 8, | |
| displayColors: false, | |
| callbacks: { | |
| label: function(context) { | |
| return `Price: $${formatNum(context.parsed.y)}`; | |
| } | |
| } | |
| } | |
| }, | |
| scales: { | |
| x: { | |
| type: 'time', | |
| time: { | |
| displayFormats: { | |
| hour: 'HH:mm', | |
| day: 'MMM dd' | |
| } | |
| }, | |
| grid: { | |
| display: true, | |
| color: 'rgba(255, 255, 255, 0.05)', | |
| lineWidth: 1 | |
| }, | |
| ticks: { | |
| color: '#64748B', | |
| font: { size: 11, family: 'Manrope' }, | |
| maxRotation: 0 | |
| }, | |
| border: { | |
| display: true, | |
| color: 'rgba(255, 255, 255, 0.1)' | |
| } | |
| }, | |
| y: { | |
| position: 'right', | |
| grid: { | |
| color: 'rgba(255, 255, 255, 0.05)', | |
| lineWidth: 1, | |
| drawBorder: false | |
| }, | |
| ticks: { | |
| color: '#64748B', | |
| font: { size: 11, family: 'Manrope' }, | |
| callback: v => '$' + formatNum(v), | |
| padding: 8 | |
| }, | |
| border: { | |
| display: true, | |
| color: 'rgba(255, 255, 255, 0.1)' | |
| } | |
| } | |
| }, | |
| elements: { | |
| line: { | |
| cubicInterpolationMode: 'monotone' | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // Volume Chart | |
| const volumeCtx = document.getElementById('volume-chart'); | |
| if (volumeCtx) { | |
| if (chartInstances.volume) chartInstances.volume.destroy(); | |
| chartInstances.volume = new Chart(volumeCtx, { | |
| type: 'bar', | |
| data: { | |
| labels: data.total_volumes.map(v => new Date(v[0])), | |
| datasets: [{ | |
| label: 'Volume', | |
| data: data.total_volumes.map(v => v[1]), | |
| backgroundColor: (ctx) => { | |
| const idx = ctx.dataIndex; | |
| if (idx === 0) return 'rgba(52, 211, 153, 0.4)'; | |
| const prev = data.total_volumes[idx - 1]?.[1] || 0; | |
| const curr = data.total_volumes[idx][1]; | |
| return curr >= prev ? 'rgba(52, 211, 153, 0.5)' : 'rgba(248, 113, 113, 0.5)'; | |
| }, | |
| borderColor: (ctx) => { | |
| const idx = ctx.dataIndex; | |
| if (idx === 0) return '#34D399'; | |
| const prev = data.total_volumes[idx - 1]?.[1] || 0; | |
| const curr = data.total_volumes[idx][1]; | |
| return curr >= prev ? '#34D399' : '#F87171'; | |
| }, | |
| borderWidth: 1, | |
| borderRadius: 2, | |
| borderSkipped: false | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { | |
| backgroundColor: 'rgba(10, 15, 30, 0.98)', | |
| padding: 12, | |
| borderColor: 'rgba(52, 211, 153, 0.3)', | |
| borderWidth: 1.5, | |
| titleColor: '#F8FAFC', | |
| bodyColor: '#E2E8F0', | |
| titleFont: { size: 12, weight: '700', family: 'Manrope' }, | |
| bodyFont: { size: 11, family: 'Manrope' }, | |
| cornerRadius: 8, | |
| displayColors: false, | |
| callbacks: { | |
| label: function(context) { | |
| return `Volume: $${formatNum(context.parsed.y)}`; | |
| } | |
| } | |
| } | |
| }, | |
| scales: { | |
| x: { | |
| type: 'time', | |
| time: { | |
| displayFormats: { | |
| hour: 'HH:mm', | |
| day: 'MMM dd' | |
| } | |
| }, | |
| grid: { | |
| display: true, | |
| color: 'rgba(255, 255, 255, 0.03)', | |
| lineWidth: 1 | |
| }, | |
| ticks: { | |
| color: '#64748B', | |
| font: { size: 10, family: 'Manrope' }, | |
| maxRotation: 0 | |
| }, | |
| border: { | |
| display: false | |
| } | |
| }, | |
| y: { | |
| position: 'right', | |
| grid: { | |
| color: 'rgba(255, 255, 255, 0.05)', | |
| lineWidth: 1, | |
| drawBorder: false | |
| }, | |
| ticks: { | |
| color: '#64748B', | |
| font: { size: 10, family: 'Manrope' }, | |
| callback: v => '$' + formatNum(v), | |
| padding: 6 | |
| }, | |
| border: { | |
| display: false | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // RSI Chart (simulated) | |
| const rsiCtx = document.getElementById('rsi-chart'); | |
| if (rsiCtx) { | |
| if (chartInstances.rsi) chartInstances.rsi.destroy(); | |
| const rsiData = calculateRSI(data.prices.map(p => p[1])); | |
| chartInstances.rsi = new Chart(rsiCtx, { | |
| type: 'line', | |
| data: { | |
| labels: rsiData.map((_, i) => i), | |
| datasets: [{ | |
| label: 'RSI (14)', | |
| data: rsiData, | |
| borderColor: '#A78BFA', | |
| backgroundColor: 'rgba(167, 139, 250, 0.12)', | |
| borderWidth: 2.5, | |
| fill: true, | |
| tension: 0, | |
| pointRadius: 0, | |
| pointHoverRadius: 4 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| display: true, | |
| position: 'top', | |
| align: 'start', | |
| labels: { | |
| usePointStyle: true, | |
| pointStyle: 'circle', | |
| padding: 14, | |
| font: { | |
| size: 12, | |
| weight: '600', | |
| family: 'Manrope' | |
| }, | |
| color: '#E2E8F0', | |
| boxWidth: 14, | |
| boxHeight: 14 | |
| } | |
| }, | |
| tooltip: { | |
| backgroundColor: 'rgba(10, 15, 30, 0.98)', | |
| padding: 12, | |
| borderColor: 'rgba(167, 139, 250, 0.3)', | |
| borderWidth: 1.5, | |
| titleColor: '#F8FAFC', | |
| bodyColor: '#E2E8F0', | |
| titleFont: { size: 12, weight: '700', family: 'Manrope' }, | |
| bodyFont: { size: 11, family: 'Manrope' }, | |
| cornerRadius: 8, | |
| displayColors: false, | |
| callbacks: { | |
| label: function(context) { | |
| return `RSI: ${context.parsed.y.toFixed(2)}`; | |
| } | |
| } | |
| } | |
| }, | |
| scales: { | |
| y: { | |
| min: 0, | |
| max: 100, | |
| grid: { | |
| color: 'rgba(255, 255, 255, 0.05)', | |
| lineWidth: 1 | |
| }, | |
| ticks: { | |
| color: '#64748B', | |
| font: { size: 11, family: 'Manrope' }, | |
| padding: 6 | |
| }, | |
| border: { | |
| display: false | |
| } | |
| }, | |
| x: { | |
| grid: { | |
| display: true, | |
| color: 'rgba(255, 255, 255, 0.03)', | |
| lineWidth: 1 | |
| }, | |
| ticks: { | |
| display: false, | |
| color: '#64748B', | |
| font: { size: 10, family: 'Manrope' } | |
| }, | |
| border: { | |
| display: false | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // MA Chart | |
| const maCtx = document.getElementById('ma-chart'); | |
| if (maCtx) { | |
| if (chartInstances.ma) chartInstances.ma.destroy(); | |
| const prices = data.prices.map(p => p[1]); | |
| const ma7 = calculateMA(prices, 7); | |
| const ma25 = calculateMA(prices, 25); | |
| const ma99 = calculateMA(prices, 99); | |
| chartInstances.ma = new Chart(maCtx, { | |
| type: 'line', | |
| data: { | |
| labels: prices.map((_, i) => i), | |
| datasets: [ | |
| { | |
| label: 'MA 7', | |
| data: ma7, | |
| borderColor: '#60A5FA', | |
| backgroundColor: 'rgba(96, 165, 250, 0.1)', | |
| borderWidth: 2.5, | |
| fill: false, | |
| tension: 0, | |
| pointRadius: 0, | |
| pointHoverRadius: 4 | |
| }, | |
| { | |
| label: 'MA 25', | |
| data: ma25, | |
| borderColor: '#34D399', | |
| backgroundColor: 'rgba(52, 211, 153, 0.1)', | |
| borderWidth: 2.5, | |
| fill: false, | |
| tension: 0, | |
| pointRadius: 0, | |
| pointHoverRadius: 4 | |
| }, | |
| { | |
| label: 'MA 99', | |
| data: ma99, | |
| borderColor: '#FBBF24', | |
| backgroundColor: 'rgba(251, 191, 36, 0.1)', | |
| borderWidth: 2.5, | |
| fill: false, | |
| tension: 0, | |
| pointRadius: 0, | |
| pointHoverRadius: 4 | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| display: true, | |
| position: 'top', | |
| align: 'start', | |
| labels: { | |
| usePointStyle: true, | |
| pointStyle: 'circle', | |
| padding: 14, | |
| font: { | |
| size: 12, | |
| weight: '600', | |
| family: 'Manrope' | |
| }, | |
| color: '#E2E8F0', | |
| boxWidth: 14, | |
| boxHeight: 14 | |
| } | |
| }, | |
| tooltip: { | |
| backgroundColor: 'rgba(10, 15, 30, 0.98)', | |
| padding: 12, | |
| borderColor: 'rgba(96, 165, 250, 0.3)', | |
| borderWidth: 1.5, | |
| titleColor: '#F8FAFC', | |
| bodyColor: '#E2E8F0', | |
| titleFont: { size: 12, weight: '700', family: 'Manrope' }, | |
| bodyFont: { size: 11, family: 'Manrope' }, | |
| cornerRadius: 8, | |
| displayColors: true, | |
| boxPadding: 6 | |
| } | |
| }, | |
| scales: { | |
| y: { | |
| grid: { | |
| color: 'rgba(255, 255, 255, 0.05)', | |
| lineWidth: 1 | |
| }, | |
| ticks: { | |
| color: '#64748B', | |
| font: { size: 11, family: 'Manrope' }, | |
| callback: v => '$' + formatNum(v), | |
| padding: 6 | |
| }, | |
| border: { | |
| display: false | |
| } | |
| }, | |
| x: { | |
| grid: { | |
| display: true, | |
| color: 'rgba(255, 255, 255, 0.03)', | |
| lineWidth: 1 | |
| }, | |
| ticks: { | |
| display: false, | |
| color: '#64748B', | |
| font: { size: 10, family: 'Manrope' } | |
| }, | |
| border: { | |
| display: false | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| } catch (e) { | |
| console.error('Detail chart error:', e); | |
| } | |
| } | |
| // Calculate RSI | |
| function calculateRSI(prices, period = 14) { | |
| const rsi = []; | |
| for (let i = period; i < prices.length; i++) { | |
| let gains = 0, losses = 0; | |
| for (let j = i - period; j < i; j++) { | |
| const change = prices[j + 1] - prices[j]; | |
| if (change > 0) gains += change; | |
| else losses -= change; | |
| } | |
| const avgGain = gains / period; | |
| const avgLoss = losses / period; | |
| const rs = avgGain / avgLoss; | |
| rsi.push(100 - (100 / (1 + rs))); | |
| } | |
| return rsi; | |
| } | |
| // Calculate Moving Average | |
| function calculateMA(prices, period) { | |
| const ma = []; | |
| for (let i = 0; i < prices.length; i++) { | |
| if (i < period - 1) { | |
| ma.push(null); | |
| } else { | |
| const sum = prices.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); | |
| ma.push(sum / period); | |
| } | |
| } | |
| return ma; | |
| } | |
| function formatNum(num) { | |
| if (num === null || num === undefined || isNaN(num)) { | |
| return '0.00'; | |
| } | |
| num = Number(num); | |
| if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T'; | |
| if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B'; | |
| if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'; | |
| if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K'; | |
| return num.toFixed(2); | |
| } | |
| // ========== NEWS SECTION ========== | |
| let newsData = []; | |
| let newsFilter = { search: '', range: 'all', symbol: '' }; | |
| async function loadNewsData() { | |
| return loadNews(); | |
| } | |
| async function loadNews() { | |
| const loadingEl = document.getElementById('news-loading'); | |
| const errorEl = document.getElementById('news-error'); | |
| const gridEl = document.getElementById('news-grid'); | |
| const countEl = document.getElementById('news-count'); | |
| try { | |
| loadingEl.style.display = 'block'; | |
| errorEl.style.display = 'none'; | |
| gridEl.innerHTML = ''; | |
| let backendUrl = window.BACKEND_URL || window.location.origin; | |
| let res = await fetch(`${backendUrl}/api/news/latest?limit=40`); | |
| // If 404, try alternative endpoints | |
| if (!res.ok && res.status === 404) { | |
| const altBackend = backendUrl.includes(':7860') ? backendUrl.replace(':7860', ':7861') : `${backendUrl}:7861`; | |
| try { | |
| res = await fetch(`${altBackend}/api/news/latest?limit=40`); | |
| if (res.ok) backendUrl = altBackend; | |
| } catch (e) { | |
| res = await fetch(`${window.location.origin}/api/news/latest?limit=40`); | |
| if (res.ok) backendUrl = window.location.origin; | |
| } | |
| } | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}: ${res.statusText}`); | |
| } | |
| const data = await res.json(); | |
| newsData = data.news || data.data || data || []; | |
| if (!Array.isArray(newsData) || newsData.length === 0) { | |
| gridEl.innerHTML = ` | |
| <div style="grid-column: 1 / -1; text-align: center; padding: 60px; color: var(--text-muted);"> | |
| <div style="font-size: 3rem; margin-bottom: 16px;">📰</div> | |
| <div>No news available at the moment</div> | |
| </div> | |
| `; | |
| countEl.textContent = '0 articles'; | |
| return; | |
| } | |
| countEl.textContent = `${newsData.length} articles`; | |
| renderNews(newsData); | |
| } catch (e) { | |
| console.error('[News] Error loading news:', e); | |
| errorEl.style.display = 'block'; | |
| const errorMessage = e.message || 'Unknown error'; | |
| const is404 = errorMessage.includes('404'); | |
| errorEl.innerHTML = ` | |
| <div style="font-size: 3rem; margin-bottom: 16px;">⚠️</div> | |
| <div style="font-size: 1.125rem; margin-bottom: 8px;">Failed to load news</div> | |
| <div style="font-size: 0.875rem; color: var(--text-muted);"> | |
| ${is404 ? 'News endpoint not available. This feature may not be configured on the backend.' : errorMessage} | |
| </div> | |
| `; | |
| gridEl.innerHTML = ''; | |
| countEl.textContent = '0 articles'; | |
| } finally { | |
| loadingEl.style.display = 'none'; | |
| } | |
| } | |
| function renderNews(news) { | |
| const gridEl = document.getElementById('news-grid'); | |
| const filtered = filterNews(news); | |
| if (filtered.length === 0) { | |
| gridEl.innerHTML = ` | |
| <div style="grid-column: 1 / -1; text-align: center; padding: 60px; color: var(--text-muted);"> | |
| <div style="font-size: 3rem; margin-bottom: 16px;">🔍</div> | |
| <div>No news found matching your filters</div> | |
| </div> | |
| `; | |
| return; | |
| } | |
| gridEl.innerHTML = filtered.map((item, idx) => { | |
| const date = item.published_at || item.date || item.timestamp || new Date().toISOString(); | |
| const timeAgo = getTimeAgo(date); | |
| const sentiment = item.sentiment || 'neutral'; | |
| const sentimentColor = sentiment === 'positive' ? 'var(--success)' : | |
| sentiment === 'negative' ? 'var(--danger)' : 'var(--text-muted)'; | |
| const sentimentIcon = sentiment === 'positive' ? '📈' : | |
| sentiment === 'negative' ? '📉' : '➡️'; | |
| const symbols = Array.isArray(item.symbols) ? item.symbols : | |
| Array.isArray(item.coins) ? item.coins : | |
| Array.isArray(item.tags) ? item.tags : []; | |
| const source = item.source || 'Unknown'; | |
| return ` | |
| <div class="glass-card news-card" style="cursor: pointer; transition: all 0.3s;" onclick="openNewsModal(${idx})"> | |
| <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;"> | |
| <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;"> | |
| <span class="chip" style="font-size: 0.75rem; padding: 4px 10px;">${source}</span> | |
| <span style="color: var(--text-muted); font-size: 0.75rem;">${timeAgo}</span> | |
| </div> | |
| <span style="font-size: 1.25rem;">${sentimentIcon}</span> | |
| </div> | |
| <h3 style="margin: 0 0 12px 0; font-size: 1.125rem; font-weight: 700; color: var(--text-primary); line-height: 1.4;"> | |
| ${(item.title || 'No title').substring(0, 100)}${(item.title || '').length > 100 ? '...' : ''} | |
| </h3> | |
| ${item.summary ? `<p style="margin: 0 0 12px 0; color: var(--text-secondary); font-size: 0.875rem; line-height: 1.5;">${item.summary.substring(0, 120)}...</p>` : ''} | |
| <div style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center;"> | |
| ${symbols.slice(0, 3).map(s => `<span class="chip" style="font-size: 0.75rem; padding: 4px 10px; background: var(--primary-glow); color: var(--primary-light);">${s}</span>`).join('')} | |
| ${symbols.length > 3 ? `<span style="color: var(--text-muted); font-size: 0.75rem;">+${symbols.length - 3} more</span>` : ''} | |
| <span style="margin-left: auto; color: ${sentimentColor}; font-size: 0.75rem; font-weight: 600; text-transform: capitalize;"> | |
| ${sentiment} | |
| </span> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| // Store filtered news for modal | |
| window.filteredNews = filtered; | |
| // Add hover effects | |
| document.querySelectorAll('.news-card').forEach(card => { | |
| card.addEventListener('mouseenter', function() { | |
| this.style.boxShadow = '0 12px 40px rgba(0, 0, 0, 0.4), var(--shadow-glow-primary)'; | |
| this.style.borderColor = 'var(--primary)'; | |
| }); | |
| card.addEventListener('mouseleave', function() { | |
| this.style.boxShadow = ''; | |
| this.style.borderColor = ''; | |
| }); | |
| }); | |
| } | |
| function filterNews(news) { | |
| return news.filter(item => { | |
| const title = (item.title || '').toLowerCase(); | |
| const searchMatch = !newsFilter.search || title.includes(newsFilter.search.toLowerCase()); | |
| const symbols = Array.isArray(item.symbols) ? item.symbols : | |
| Array.isArray(item.coins) ? item.coins : []; | |
| const symbolMatch = !newsFilter.symbol || | |
| symbols.some(s => s.toLowerCase().includes(newsFilter.symbol.toLowerCase())); | |
| return searchMatch && symbolMatch; | |
| }); | |
| } | |
| function getTimeAgo(dateStr) { | |
| try { | |
| const date = new Date(dateStr); | |
| const now = new Date(); | |
| const diff = now - date; | |
| const minutes = Math.floor(diff / 60000); | |
| const hours = Math.floor(diff / 3600000); | |
| const days = Math.floor(diff / 86400000); | |
| if (minutes < 1) return 'Just now'; | |
| if (minutes < 60) return `${minutes}m ago`; | |
| if (hours < 24) return `${hours}h ago`; | |
| if (days < 7) return `${days}d ago`; | |
| return date.toLocaleDateString(); | |
| } catch { | |
| return 'Recently'; | |
| } | |
| } | |
| function openNewsModal(index) { | |
| const item = window.filteredNews[index]; | |
| if (!item) return; | |
| const modal = document.querySelector('[data-news-modal]'); | |
| const content = document.querySelector('[data-news-modal-content]'); | |
| const sentiment = item.sentiment || 'neutral'; | |
| const sentimentColor = sentiment === 'positive' ? 'var(--success)' : | |
| sentiment === 'negative' ? 'var(--danger)' : 'var(--text-muted)'; | |
| content.innerHTML = ` | |
| <div style="margin-bottom: 20px;"> | |
| <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px;"> | |
| <div> | |
| <span class="chip" style="margin-bottom: 8px; display: inline-block;">${item.source || 'Unknown'}</span> | |
| <div style="color: var(--text-muted); font-size: 0.875rem;">${getTimeAgo(item.published_at || item.date)}</div> | |
| </div> | |
| <span style="color: ${sentimentColor}; font-weight: 600; text-transform: capitalize;">${sentiment}</span> | |
| </div> | |
| <h2 style="font-size: 1.75rem; font-weight: 800; margin: 0 0 16px 0; color: var(--text-primary); line-height: 1.3;"> | |
| ${item.title || 'No title'} | |
| </h2> | |
| </div> | |
| <div style="margin-bottom: 20px; color: var(--text-secondary); line-height: 1.6;"> | |
| ${item.content || item.summary || item.description || 'No content available.'} | |
| </div> | |
| ${item.url ? ` | |
| <div style="margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--glass-border);"> | |
| <a href="${item.url}" target="_blank" class="btn-secondary" style="display: inline-flex; align-items: center; gap: 8px;"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none"> | |
| <path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| Read Full Article | |
| </a> | |
| </div> | |
| ` : ''} | |
| `; | |
| modal.style.display = 'flex'; | |
| } | |
| // ========== AI ADVISOR & SENTIMENT ANALYSIS ========== | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Query Form Handler | |
| const queryForm = document.querySelector('[data-query-form]'); | |
| if (queryForm) { | |
| queryForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(queryForm); | |
| const query = formData.get('query'); | |
| const output = document.querySelector('[data-query-output]'); | |
| if (!query || !query.trim()) { | |
| output.innerHTML = '<div style="color: var(--text-muted);">Please enter a query</div>'; | |
| return; | |
| } | |
| output.innerHTML = '<div style="color: var(--text-secondary); display: flex; align-items: center; gap: 8px;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/></svg> Processing...</div>'; | |
| try { | |
| const backendUrl = window.BACKEND_URL || window.location.origin; | |
| const res = await fetch(`${backendUrl}/api/query`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ query }) | |
| }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const data = await res.json(); | |
| output.innerHTML = ` | |
| <div style="color: var(--text-primary); line-height: 1.6;"> | |
| <div style="font-weight: 600; margin-bottom: 8px; color: var(--primary-light);">Response:</div> | |
| <div>${data.response || data.answer || data.message || JSON.stringify(data, null, 2)}</div> | |
| </div> | |
| `; | |
| } catch (e) { | |
| output.innerHTML = `<div style="color: var(--danger);">Error: ${e.message}</div>`; | |
| } | |
| }); | |
| } | |
| // Sentiment Form Handler | |
| const sentimentForm = document.querySelector('[data-sentiment-form]'); | |
| if (sentimentForm) { | |
| sentimentForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(sentimentForm); | |
| const text = formData.get('text'); | |
| const output = document.getElementById('sentiment-results'); | |
| const outputContent = document.querySelector('[data-sentiment-output]'); | |
| if (!text || !text.trim()) { | |
| outputContent.innerHTML = '<div style="color: var(--text-muted);">Please enter text for analysis</div>'; | |
| return; | |
| } | |
| output.style.display = 'block'; | |
| outputContent.innerHTML = '<div style="color: var(--text-secondary); display: flex; align-items: center; gap: 8px;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Analyzing sentiment...</div>'; | |
| try { | |
| const backendUrl = window.BACKEND_URL || window.location.origin; | |
| const res = await fetch(`${backendUrl}/api/sentiment/analyze`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text }) | |
| }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const data = await res.json(); | |
| const sentiment = data.sentiment || data.label || 'neutral'; | |
| const confidence = data.confidence || data.score || 0; | |
| const isPositive = sentiment.toLowerCase().includes('positive') || sentiment.toLowerCase().includes('bullish'); | |
| const isNegative = sentiment.toLowerCase().includes('negative') || sentiment.toLowerCase().includes('bearish'); | |
| const sentimentColor = isPositive ? 'var(--success)' : isNegative ? 'var(--danger)' : 'var(--text-muted)'; | |
| const sentimentIcon = isPositive ? '📈' : isNegative ? '📉' : '➡️'; | |
| const sentimentBg = isPositive ? 'rgba(52, 211, 153, 0.15)' : isNegative ? 'rgba(248, 113, 113, 0.15)' : 'var(--glass-bg-light)'; | |
| outputContent.innerHTML = ` | |
| <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 24px;"> | |
| <div style="padding: 20px; background: ${sentimentBg}; border-radius: 16px; border: 2px solid ${sentimentColor}; text-align: center;"> | |
| <div style="font-size: 3rem; margin-bottom: 12px;">${sentimentIcon}</div> | |
| <div style="font-size: 1.5rem; font-weight: 800; color: ${sentimentColor}; margin-bottom: 8px; text-transform: capitalize;">${sentiment}</div> | |
| <div style="font-size: 0.875rem; color: var(--text-muted);">Confidence: ${(confidence * 100).toFixed(1)}%</div> | |
| </div> | |
| <div style="padding: 20px; background: var(--glass-bg-light); border-radius: 16px; border: 1px solid var(--glass-border);"> | |
| <div style="font-weight: 600; color: var(--text-secondary); margin-bottom: 12px;">Details</div> | |
| <div style="color: var(--text-primary); line-height: 1.6; font-size: 0.9375rem;"> | |
| ${data.explanation || data.reasoning || 'Analysis completed using ensemble AI models.'} | |
| </div> | |
| </div> | |
| </div> | |
| ${data.models_used ? ` | |
| <div style="padding: 16px; background: var(--glass-bg-light); border-radius: 12px; border: 1px solid var(--glass-border);"> | |
| <div style="font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; font-size: 0.875rem;">Models Used:</div> | |
| <div style="display: flex; gap: 8px; flex-wrap: wrap;"> | |
| ${data.models_used.map(m => `<span class="chip" style="font-size: 0.75rem;">${m}</span>`).join('')} | |
| </div> | |
| </div> | |
| ` : ''} | |
| `; | |
| } catch (e) { | |
| outputContent.innerHTML = `<div style="color: var(--danger);">Error: ${e.message}</div>`; | |
| } | |
| }); | |
| } | |
| }); | |
| // Initialize news section | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const newsPage = document.getElementById('page-news'); | |
| if (newsPage) { | |
| // Load news when page becomes active | |
| const observer = new MutationObserver((mutations) => { | |
| if (newsPage.classList.contains('active') && newsData.length === 0) { | |
| loadNews(); | |
| } | |
| }); | |
| observer.observe(newsPage, { attributes: true, attributeFilter: ['class'] }); | |
| // Refresh button | |
| document.getElementById('refresh-news')?.addEventListener('click', loadNews); | |
| // Search input | |
| document.querySelector('[data-news-search]')?.addEventListener('input', (e) => { | |
| newsFilter.search = e.target.value; | |
| renderNews(newsData); | |
| }); | |
| // Range select | |
| document.querySelector('[data-news-range]')?.addEventListener('change', (e) => { | |
| newsFilter.range = e.target.value; | |
| loadNews(); | |
| }); | |
| // Symbol input | |
| document.querySelector('[data-news-symbol]')?.addEventListener('input', (e) => { | |
| newsFilter.symbol = e.target.value; | |
| renderNews(newsData); | |
| }); | |
| // Close modal | |
| document.querySelector('[data-close-news-modal]')?.addEventListener('click', () => { | |
| document.querySelector('[data-news-modal]').style.display = 'none'; | |
| }); | |
| // Close modal on backdrop click | |
| document.querySelector('[data-news-modal]')?.addEventListener('click', (e) => { | |
| if (e.target === e.currentTarget) { | |
| e.currentTarget.style.display = 'none'; | |
| } | |
| }); | |
| } | |
| }); | |
| </script> | |
| <!-- Load Config JS --> | |
| <script src="config.js?v=20250119"></script> | |
| <!-- Optional Enhanced JS Utilities (Loaded conditionally) --> | |
| <script src="static/js/icons.js" defer></script> | |
| <script src="static/js/toast.js" defer></script> | |
| <script src="static/js/theme-manager.js" defer></script> | |
| <script src="static/js/animations.js" defer></script> | |
| <script src="static/js/uiUtils.js?v=20250119" defer></script> | |
| <script src="static/js/accessibility.js" defer></script> | |
| <script src="static/js/provider-discovery.js" defer></script> | |
| <script src="static/js/api-resource-loader.js?v=20250120" defer></script> | |
| <!-- Force localhost override script --> | |
| <script> | |
| // Immediate override for localhost - runs before any other scripts | |
| if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { | |
| if (window.DASHBOARD_CONFIG) { | |
| window.DASHBOARD_CONFIG.BACKEND_URL = `http://${window.location.hostname}:7860`; | |
| window.DASHBOARD_CONFIG.WS_URL = `ws://${window.location.hostname}:7860/ws`; | |
| } | |
| window.BACKEND_URL = `http://${window.location.hostname}:7860`; | |
| } | |
| console.log('[Init] Hostname:', window.location.hostname); | |
| console.log('[Init] Backend URL:', window.BACKEND_URL || window.DASHBOARD_CONFIG?.BACKEND_URL || 'not set'); | |
| </script> | |
| <!-- Initialize Enhanced Features --> | |
| <script> | |
| // Initialize enhanced features when DOM is ready | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Initialize Toast Manager (if available) | |
| if (typeof ToastManager !== 'undefined') { | |
| window.toast = new ToastManager(); | |
| console.log('[Init] Toast manager initialized'); | |
| } | |
| // Initialize Theme Manager (if available) | |
| if (typeof ThemeManager !== 'undefined') { | |
| window.themeManager = new ThemeManager(); | |
| window.themeManager.init(); | |
| console.log('[Init] Theme manager initialized'); | |
| } | |
| // Initialize Animation Controller (if available) | |
| if (typeof AnimationController !== 'undefined') { | |
| window.animations = new AnimationController(); | |
| console.log('[Init] Animation controller initialized'); | |
| } | |
| // Initialize Provider Discovery (if available) | |
| if (typeof ProviderDiscoveryEngine !== 'undefined') { | |
| window.providerDiscovery = new ProviderDiscoveryEngine(); | |
| window.providerDiscovery.init().catch(e => { | |
| console.warn('[Init] Provider discovery failed:', e); | |
| }); | |
| } | |
| // Initialize Accessibility (if available) | |
| if (typeof AccessibilityManager !== 'undefined') { | |
| window.accessibility = new AccessibilityManager(); | |
| window.accessibility.init(); | |
| console.log('[Init] Accessibility manager initialized'); | |
| } | |
| // Initialize API Resource Loader (if available) | |
| if (window.apiResourceLoader && !window.apiResourceLoader.initialized) { | |
| window.apiResourceLoader.init().then(() => { | |
| const stats = window.apiResourceLoader.getStats(); | |
| // Only log/show toast if resources were actually loaded | |
| if (stats.unified.count > 0 || stats.ultimate.count > 0) { | |
| console.log('[Init] API Resource Loader ready:', stats); | |
| // Show toast notification if available | |
| if (window.toast) { | |
| window.toast.show( | |
| `Loaded ${stats.unified.count + stats.ultimate.count} API resources`, | |
| 'success', | |
| { title: 'Resources Loaded', duration: 3000 } | |
| ); | |
| } | |
| } | |
| // Silently skip if no resources loaded - they're optional | |
| }).catch(() => { | |
| // Completely silent - resources are optional | |
| }); | |
| } | |
| // Enhance UI with smooth animations | |
| if (window.animations) { | |
| // Add fade-in animation to cards | |
| document.querySelectorAll('.glass-card, .stat-card').forEach((card, index) => { | |
| card.style.opacity = '0'; | |
| card.style.transform = 'translateY(20px)'; | |
| setTimeout(() => { | |
| card.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; | |
| card.style.opacity = '1'; | |
| card.style.transform = 'translateY(0)'; | |
| }, index * 50); | |
| }); | |
| } | |
| }); | |
| </script> | |
| <!-- Load App JS as ES6 Module --> | |
| <script type="module" src="static/js/app.js?v=20250119"></script> | |
| </body> | |
| </html> | |