| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>NBA Buzz - Player Mentions Tracker</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| background: #f5f5f5; |
| padding: 20px; |
| color: #333; |
| } |
| .container { max-width: 900px; margin: 0 auto; } |
| |
| .search-container { |
| position: relative; |
| margin-bottom: 20px; |
| } |
| .search-input { |
| width: 100%; |
| padding: 15px 20px; |
| font-size: 16px; |
| border: 2px solid #e0e0e0; |
| border-radius: 10px; |
| outline: none; |
| } |
| .search-input:focus { border-color: #f97316; } |
| .search-results { |
| position: absolute; |
| top: 100%; |
| left: 0; |
| right: 0; |
| background: white; |
| border: 2px solid #e0e0e0; |
| border-top: none; |
| border-radius: 0 0 10px 10px; |
| max-height: 300px; |
| overflow-y: auto; |
| z-index: 100; |
| display: none; |
| } |
| .search-results.active { display: block; } |
| .search-result { |
| padding: 12px 20px; |
| cursor: pointer; |
| border-bottom: 1px solid #f0f0f0; |
| display: flex; |
| justify-content: space-between; |
| text-decoration: none; |
| color: inherit; |
| } |
| .search-result:hover { background: #fff8f3; } |
| .search-result .name { font-weight: 600; } |
| .search-result .type { font-size: 12px; color: #888; } |
| |
| .stats-banner { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 15px; |
| margin-bottom: 20px; |
| } |
| .stat-box { |
| text-align: center; |
| padding: 15px; |
| background: white; |
| border-radius: 8px; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.08); |
| } |
| .stat-box .number { font-size: 24px; font-weight: bold; color: #f97316; } |
| .stat-box .label { font-size: 11px; color: #7f8c8d; margin-top: 5px; text-transform: uppercase; } |
| |
| .status-bar { |
| text-align: center; |
| padding: 10px; |
| font-size: 13px; |
| color: #888; |
| background: #f8f9fa; |
| border-radius: 6px; |
| margin-bottom: 20px; |
| } |
| .status-bar.fetching { background: #fef3c7; color: #92400e; } |
| |
| .controls { |
| background: white; |
| border-radius: 10px; |
| padding: 15px 20px; |
| margin-bottom: 20px; |
| box-shadow: 0 2px 8px rgba(0,0,0,0.08); |
| display: flex; |
| flex-wrap: wrap; |
| gap: 15px; |
| align-items: center; |
| justify-content: space-between; |
| } |
| .control-group { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| .control-group label { |
| font-weight: 600; |
| color: #555; |
| font-size: 14px; |
| } |
| .time-filters { |
| display: flex; |
| gap: 5px; |
| } |
| .time-btn { |
| padding: 8px 12px; |
| border: none; |
| background: #f0f0f0; |
| border-radius: 6px; |
| cursor: pointer; |
| font-size: 13px; |
| font-weight: 500; |
| transition: all 0.2s; |
| } |
| .time-btn:hover { background: #e0e0e0; } |
| .time-btn.active { background: #f97316; color: white; } |
| |
| .tab-buttons { |
| display: flex; |
| gap: 10px; |
| } |
| .tab-btn { |
| padding: 8px 16px; |
| border: 2px solid #e0e0e0; |
| background: white; |
| border-radius: 8px; |
| cursor: pointer; |
| font-size: 14px; |
| font-weight: 600; |
| transition: all 0.2s; |
| } |
| .tab-btn:hover { border-color: #f97316; } |
| .tab-btn.active { border-color: #f97316; background: #fff8f3; color: #f97316; } |
| |
| .refresh-btn { |
| padding: 8px 16px; |
| border: none; |
| background: #f97316; |
| color: white; |
| border-radius: 8px; |
| cursor: pointer; |
| font-size: 14px; |
| font-weight: 600; |
| } |
| .refresh-btn:hover { background: #ea580c; } |
| .refresh-btn:disabled { background: #ccc; cursor: not-allowed; } |
| |
| .rankings-panel { |
| background: white; |
| border-radius: 12px; |
| box-shadow: 0 2px 8px rgba(0,0,0,0.08); |
| overflow: hidden; |
| } |
| .panel-header { |
| padding: 20px 20px 15px 20px; |
| font-size: 18px; |
| font-weight: 600; |
| color: #333; |
| border-bottom: 2px solid #f97316; |
| } |
| |
| .ranking-item { |
| display: flex; |
| align-items: center; |
| padding: 15px 20px; |
| border-bottom: 1px solid #f0f0f0; |
| text-decoration: none; |
| color: inherit; |
| transition: background 0.2s; |
| } |
| .ranking-item:hover { background: #fff8f3; } |
| .rank-num { |
| width: 32px; |
| height: 32px; |
| background: #f0f0f0; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-weight: bold; |
| color: #666; |
| margin-right: 15px; |
| flex-shrink: 0; |
| } |
| .ranking-item:nth-child(-n+5) .rank-num { background: #f97316; color: white; } |
| .player-info { flex: 1; min-width: 0; } |
| .player-name { font-weight: 600; font-size: 15px; color: #333; } |
| .player-team { font-size: 13px; color: #f97316; margin-top: 2px; } |
| .latest-mention { font-size: 12px; color: #666; margin-top: 4px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } |
| .mention-count { text-align: right; margin-right: 20px; } |
| .mention-num { font-size: 20px; font-weight: bold; color: #f97316; } |
| .mention-label { font-size: 10px; color: #888; text-transform: uppercase; } |
| .sentiment-dots { display: flex; gap: 12px; } |
| .sentiment-dot { font-size: 12px; font-weight: 500; } |
| .sentiment-dot.positive { color: #22c55e; } |
| .sentiment-dot.neutral { color: #888; } |
| .sentiment-dot.negative { color: #ef4444; } |
| |
| .loading { text-align: center; padding: 40px; color: #888; } |
| .spinner { |
| width: 40px; |
| height: 40px; |
| border: 4px solid #f0f0f0; |
| border-top-color: #f97316; |
| border-radius: 50%; |
| animation: spin 1s linear infinite; |
| margin: 0 auto 15px; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .empty-state { text-align: center; padding: 40px; color: #888; } |
| .empty-state h3 { margin-bottom: 10px; color: #666; } |
| |
| .legal-note { |
| text-align: center; |
| font-size: 11px; |
| color: #aaa; |
| margin-top: 20px; |
| padding-top: 15px; |
| border-top: 1px solid #eee; |
| } |
| |
| @media (max-width: 600px) { |
| .stats-banner { grid-template-columns: repeat(2, 1fr); } |
| .controls { flex-direction: column; align-items: stretch; } |
| .tab-buttons { margin-left: 0; justify-content: center; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="search-container"> |
| <input type="text" class="search-input" id="searchInput" placeholder="Search players or teams..."> |
| <div class="search-results" id="searchResults"></div> |
| </div> |
| |
| <div id="statusBar" class="status-bar">Loading...</div> |
| |
| <div class="stats-banner"> |
| <div class="stat-box"> |
| <div class="number" id="statPosts">-</div> |
| <div class="label">Posts</div> |
| </div> |
| <div class="stat-box"> |
| <div class="number" id="statMentions">-</div> |
| <div class="label">Mentions</div> |
| </div> |
| <div class="stat-box"> |
| <div class="number" id="statPlayers">-</div> |
| <div class="label">Players</div> |
| </div> |
| <div class="stat-box"> |
| <div class="number" id="statUpdated">-</div> |
| <div class="label">Updated</div> |
| </div> |
| </div> |
| |
| <div class="controls"> |
| <div class="control-group"> |
| <label>Time:</label> |
| <div class="time-filters"> |
| <button class="time-btn" data-hours="6">6h</button> |
| <button class="time-btn" data-hours="12">12h</button> |
| <button class="time-btn active" data-hours="24">24h</button> |
| <button class="time-btn" data-hours="48">48h</button> |
| <button class="time-btn" data-hours="168">7d</button> |
| </div> |
| </div> |
| |
| <div class="tab-buttons"> |
| <button class="tab-btn active" data-tab="players">π€ Players</button> |
| <button class="tab-btn" data-tab="teams">π Teams</button> |
| </div> |
| |
| <button class="refresh-btn" id="refreshBtn">π Refresh</button> |
| </div> |
| |
| <div class="rankings-panel"> |
| <div class="panel-header" id="rankingsHeader">Trending Players</div> |
| <div id="rankingsList"> |
| <div class="loading"> |
| <div class="spinner"></div> |
| <p>Loading...</p> |
| </div> |
| </div> |
| </div> |
| |
| <div class="legal-note"> |
| Data sourced from Bluesky's public AT Protocol API. All posts are publicly available. |
| </div> |
| </div> |
| |
| <script> |
| let currentTab = 'players'; |
| let hours = 24; |
| let searchTimeout; |
| |
| |
| const urlParams = new URLSearchParams(window.location.search); |
| if (urlParams.get('hours')) { |
| hours = parseInt(urlParams.get('hours')); |
| } |
| if (urlParams.get('tab')) { |
| currentTab = urlParams.get('tab'); |
| } |
| |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| |
| document.querySelectorAll('.time-btn').forEach(btn => { |
| btn.classList.remove('active'); |
| if (parseInt(btn.dataset.hours) === hours) { |
| btn.classList.add('active'); |
| } |
| }); |
| |
| document.querySelectorAll('.tab-btn').forEach(btn => { |
| btn.classList.remove('active'); |
| if (btn.dataset.tab === currentTab) { |
| btn.classList.add('active'); |
| } |
| }); |
| }); |
| |
| |
| const searchInput = document.getElementById('searchInput'); |
| const searchResults = document.getElementById('searchResults'); |
| |
| searchInput.addEventListener('input', (e) => { |
| clearTimeout(searchTimeout); |
| const q = e.target.value.trim(); |
| if (q.length < 2) { |
| searchResults.classList.remove('active'); |
| return; |
| } |
| searchTimeout = setTimeout(() => doSearch(q), 200); |
| }); |
| |
| searchInput.addEventListener('focus', () => { |
| if (searchInput.value.length >= 2) searchResults.classList.add('active'); |
| }); |
| |
| document.addEventListener('click', (e) => { |
| if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) { |
| searchResults.classList.remove('active'); |
| } |
| }); |
| |
| async function doSearch(q) { |
| try { |
| const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`); |
| const data = await res.json(); |
| |
| let html = ''; |
| data.players.forEach(p => { |
| html += `<a href="/player/${encodeURIComponent(p)}" class="search-result"> |
| <span class="name">${p}</span> |
| <span class="type">Player</span> |
| </a>`; |
| }); |
| data.teams.forEach(t => { |
| html += `<a href="/team/${encodeURIComponent(t)}" class="search-result"> |
| <span class="name">${t}</span> |
| <span class="type">Team</span> |
| </a>`; |
| }); |
| |
| searchResults.innerHTML = html || '<div class="search-result">No results found</div>'; |
| searchResults.classList.add('active'); |
| } catch (e) { |
| console.error(e); |
| } |
| } |
| |
| async function loadStatus() { |
| try { |
| const res = await fetch('/api/status'); |
| const data = await res.json(); |
| |
| document.getElementById('statPosts').textContent = data.total_posts.toLocaleString(); |
| document.getElementById('statMentions').textContent = data.total_mentions.toLocaleString(); |
| document.getElementById('statPlayers').textContent = data.unique_players; |
| |
| if (data.last_update) { |
| const d = new Date(data.last_update); |
| document.getElementById('statUpdated').textContent = d.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'}); |
| } |
| |
| const statusBar = document.getElementById('statusBar'); |
| if (data.is_fetching) { |
| statusBar.className = 'status-bar fetching'; |
| statusBar.textContent = 'π ' + data.fetch_status; |
| } else { |
| statusBar.className = 'status-bar'; |
| statusBar.textContent = 'β Data loaded β’ Click a player or team for details'; |
| } |
| } catch (e) { console.error(e); } |
| } |
| |
| function escapeHtml(t) { |
| const d = document.createElement('div'); |
| d.textContent = t; |
| return d.innerHTML; |
| } |
| |
| async function loadRankings() { |
| const list = document.getElementById('rankingsList'); |
| list.innerHTML = '<div class="loading"><div class="spinner"></div><p>Loading...</p></div>'; |
| |
| const endpoint = currentTab === 'players' ? '/api/players' : '/api/teams'; |
| document.getElementById('rankingsHeader').textContent = currentTab === 'players' ? 'Trending Players' : 'Trending Teams'; |
| |
| try { |
| const res = await fetch(`${endpoint}?hours=${hours}`); |
| const data = await res.json(); |
| |
| if (data.length === 0) { |
| list.innerHTML = '<div class="empty-state"><h3>No data yet</h3><p>Click Refresh to load data</p></div>'; |
| return; |
| } |
| |
| list.innerHTML = data.map((item, index) => { |
| const name = item.player || item.team; |
| const team = currentTab === 'players' ? (item.team || '') : ''; |
| const url = currentTab === 'players' ? `/player/${encodeURIComponent(name)}` : `/team/${encodeURIComponent(name)}`; |
| |
| |
| let latestMentionHtml = ''; |
| if (index < 5 && item.latest_mention) { |
| const truncated = item.latest_mention.length > 150 |
| ? item.latest_mention.substring(0, 150) + '...' |
| : item.latest_mention; |
| latestMentionHtml = `<div class="latest-mention">"${escapeHtml(truncated)}"</div>`; |
| } |
| |
| return ` |
| <a href="${url}" class="ranking-item"> |
| <div class="rank-num">${item.rank}</div> |
| <div class="player-info"> |
| <div class="player-name">${name}</div> |
| ${team ? `<div class="player-team">${team}</div>` : ''} |
| ${latestMentionHtml} |
| </div> |
| <div class="mention-count"> |
| <div class="mention-num">${item.mentions}</div> |
| <div class="mention-label">mentions</div> |
| </div> |
| </a> |
| `; |
| }).join(''); |
| } catch (e) { |
| list.innerHTML = '<div class="empty-state"><h3>Error loading data</h3></div>'; |
| } |
| } |
| |
| document.querySelectorAll('.tab-btn').forEach(btn => { |
| btn.addEventListener('click', () => { |
| document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
| currentTab = btn.dataset.tab; |
| loadRankings(); |
| }); |
| }); |
| |
| document.querySelectorAll('.time-btn').forEach(btn => { |
| btn.addEventListener('click', () => { |
| document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
| hours = parseInt(btn.dataset.hours); |
| loadRankings(); |
| }); |
| }); |
| |
| document.getElementById('refreshBtn').addEventListener('click', async () => { |
| const btn = document.getElementById('refreshBtn'); |
| btn.disabled = true; |
| btn.textContent = 'β³ Refreshing...'; |
| |
| await fetch('/api/refresh', { method: 'POST' }); |
| |
| const check = async () => { |
| const res = await fetch('/api/status'); |
| const data = await res.json(); |
| if (data.is_fetching) { |
| setTimeout(check, 2000); |
| } else { |
| btn.disabled = false; |
| btn.textContent = 'π Refresh'; |
| loadStatus(); |
| loadRankings(); |
| } |
| }; |
| setTimeout(check, 1000); |
| }); |
| |
| loadStatus(); |
| loadRankings(); |
| setInterval(loadStatus, 10000); |
| </script> |
| </body> |
| </html> |