diff --git a/api/app.py b/api/app.py index c70bea0..876ad70 100644 --- a/api/app.py +++ b/api/app.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """JAESWIFT HUD Backend API""" -import json, os, time, subprocess, random, datetime, hashlib, zipfile, io, smtplib +import json, os, time, subprocess, random, datetime, hashlib, zipfile, io, smtplib, threading from functools import wraps from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path @@ -978,5 +978,113 @@ def awesomelist_search(): if len(results) >= limit: break return jsonify({'query': q, 'results': results, 'total': len(results)}) + +# ─── RADAR: Live Tech News Feed ────────────────────── +import feedparser + +RADAR_CACHE = {'items': [], 'last_fetch': 0} +RADAR_LOCK = threading.Lock() +RADAR_TTL = 900 # 15 minutes + +RADAR_FEEDS = { + 'hackernews': { + 'url': 'https://hnrss.org/frontpage?count=50', + 'label': 'HACKER NEWS', + 'color': '#ff6600' + }, + 'reddit_technology': { + 'url': 'https://www.reddit.com/r/technology/hot.rss?limit=30', + 'label': 'R/TECHNOLOGY', + 'color': '#ff4500' + }, + 'reddit_programming': { + 'url': 'https://www.reddit.com/r/programming/hot.rss?limit=30', + 'label': 'R/PROGRAMMING', + 'color': '#ff4500' + }, + 'reddit_netsec': { + 'url': 'https://www.reddit.com/r/netsec/hot.rss?limit=30', + 'label': 'R/NETSEC', + 'color': '#ff4500' + }, + 'lobsters': { + 'url': 'https://lobste.rs/rss', + 'label': 'LOBSTERS', + 'color': '#ac130d' + } +} + +def fetch_radar_feeds(): + items = [] + for src_id, src in RADAR_FEEDS.items(): + try: + feed = feedparser.parse(src['url']) + for entry in feed.entries: + pub = '' + if hasattr(entry, 'published_parsed') and entry.published_parsed: + pub = time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed) + elif hasattr(entry, 'updated_parsed') and entry.updated_parsed: + pub = time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.updated_parsed) + + # Extract points/score from HN + score = 0 + comments = 0 + comments_url = '' + if 'hnrss' in src['url']: + # HN RSS includes comments link and points in description + if hasattr(entry, 'comments'): + comments_url = entry.comments + + items.append({ + 'title': entry.get('title', 'Untitled'), + 'url': entry.get('link', ''), + 'source': src['label'], + 'source_id': src_id, + 'source_color': src['color'], + 'published': pub, + 'comments_url': comments_url, + 'summary': (entry.get('summary', '') or '')[:200] + }) + except Exception as e: + print(f'RADAR feed error ({src_id}): {e}') + # Sort by published date descending + items.sort(key=lambda x: x.get('published', ''), reverse=True) + return items + +def get_radar_items(): + now = time.time() + with RADAR_LOCK: + if now - RADAR_CACHE['last_fetch'] > RADAR_TTL or not RADAR_CACHE['items']: + RADAR_CACHE['items'] = fetch_radar_feeds() + RADAR_CACHE['last_fetch'] = now + return RADAR_CACHE['items'] + +@app.route('/api/radar') +def api_radar(): + source = request.args.get('source', 'all').lower() + q = request.args.get('q', '').strip().lower() + limit = min(int(request.args.get('limit', 200)), 500) + + items = get_radar_items() + + if source != 'all': + items = [i for i in items if i['source_id'] == source or i['source'].lower() == source] + if q: + items = [i for i in items if q in i['title'].lower() or q in i.get('summary', '').lower()] + + return jsonify({ + 'total': len(items[:limit]), + 'sources': list(RADAR_FEEDS.keys()), + 'last_updated': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(RADAR_CACHE.get('last_fetch', 0))), + 'items': items[:limit] + }) + +@app.route('/api/radar/refresh', methods=['POST']) +def api_radar_refresh(): + with RADAR_LOCK: + RADAR_CACHE['items'] = fetch_radar_feeds() + RADAR_CACHE['last_fetch'] = time.time() + return jsonify({'status': 'ok', 'total': len(RADAR_CACHE['items'])}) + if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/css/radar.css b/css/radar.css new file mode 100644 index 0000000..c0967f2 --- /dev/null +++ b/css/radar.css @@ -0,0 +1,316 @@ +/* ─── RADAR: Live Intelligence Feed ─────────────── */ + +.radar-container { + max-width: 1400px; + margin: 0 auto; + padding: 0 2rem 3rem; +} + +/* ─── Controls Bar ─────────────────────────────── */ +.radar-controls { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.radar-filters { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.radar-filter { + font-family: 'JetBrains Mono', monospace; + font-size: 0.6rem; + letter-spacing: 1.5px; + padding: 0.4rem 0.8rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.35); + cursor: pointer; + transition: all 0.2s ease; +} + +.radar-filter:hover { + background: rgba(255, 170, 0, 0.06); + border-color: rgba(255, 170, 0, 0.2); + color: rgba(255, 170, 0, 0.7); +} + +.radar-filter.active { + background: rgba(255, 170, 0, 0.08); + border-color: rgba(255, 170, 0, 0.4); + color: rgba(255, 170, 0, 0.9); +} + +.radar-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.radar-search { + font-family: 'JetBrains Mono', monospace; + font-size: 0.65rem; + letter-spacing: 1px; + padding: 0.4rem 0.8rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); + width: 200px; + outline: none; + transition: border-color 0.2s; +} + +.radar-search:focus { + border-color: rgba(255, 170, 0, 0.4); +} + +.radar-search::placeholder { + color: rgba(255, 255, 255, 0.2); +} + +.radar-refresh { + font-family: 'JetBrains Mono', monospace; + font-size: 0.6rem; + letter-spacing: 1px; + padding: 0.4rem 0.8rem; + background: rgba(255, 170, 0, 0.06); + border: 1px solid rgba(255, 170, 0, 0.2); + color: rgba(255, 170, 0, 0.7); + cursor: pointer; + transition: all 0.2s; +} + +.radar-refresh:hover { + background: rgba(255, 170, 0, 0.12); + border-color: rgba(255, 170, 0, 0.5); + color: rgba(255, 170, 0, 1); +} + +.radar-refresh.spinning { + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ─── Stats Bar ────────────────────────────────── */ +.radar-stats { + display: flex; + gap: 1.5rem; + margin-bottom: 1.5rem; + padding: 0.6rem 1rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + flex-wrap: wrap; +} + +.radar-stat { + font-family: 'JetBrains Mono', monospace; + font-size: 0.6rem; + letter-spacing: 1px; + color: rgba(255, 255, 255, 0.4); +} + +.stat-label { + color: rgba(255, 255, 255, 0.2); +} + +.stat-auto { + color: rgba(0, 204, 68, 0.7); +} + +.radar-live { + display: flex; + align-items: center; + gap: 0.4rem; + color: rgba(0, 204, 68, 0.8); + margin-left: auto; +} + +.live-dot { + width: 6px; + height: 6px; + background: #00cc44; + border-radius: 50%; + animation: pulse-dot 1.5s ease infinite; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; box-shadow: 0 0 4px #00cc44; } + 50% { opacity: 0.4; box-shadow: 0 0 1px #00cc44; } +} + +/* ─── Feed Items ───────────────────────────────── */ +.radar-feed { + display: flex; + flex-direction: column; + gap: 0; +} + +.radar-loading { + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + color: rgba(255, 170, 0, 0.5); + text-align: center; + padding: 3rem; + letter-spacing: 2px; + animation: pulse-text 1.5s ease infinite; +} + +@keyframes pulse-text { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +.radar-item { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 0.8rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.15s; +} + +.radar-item:hover { + background: rgba(255, 255, 255, 0.02); +} + +.radar-item-time { + font-family: 'JetBrains Mono', monospace; + font-size: 0.55rem; + color: rgba(255, 255, 255, 0.18); + min-width: 55px; + letter-spacing: 0.5px; + padding-top: 0.15rem; + flex-shrink: 0; +} + +.radar-item-source { + font-family: 'JetBrains Mono', monospace; + font-size: 0.5rem; + letter-spacing: 1.5px; + padding: 0.15rem 0.5rem; + min-width: 90px; + text-align: center; + flex-shrink: 0; + border: 1px solid; +} + +.source-hackernews { + color: #ff6600; + border-color: rgba(255, 102, 0, 0.3); + background: rgba(255, 102, 0, 0.05); +} + +.source-reddit_technology, +.source-reddit_programming, +.source-reddit_netsec { + color: #ff4500; + border-color: rgba(255, 69, 0, 0.3); + background: rgba(255, 69, 0, 0.05); +} + +.source-lobsters { + color: #ac130d; + border-color: rgba(172, 19, 13, 0.3); + background: rgba(172, 19, 13, 0.05); +} + +.radar-item-content { + flex: 1; + min-width: 0; +} + +.radar-item-title { + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.75); + text-decoration: none; + display: block; + line-height: 1.4; + transition: color 0.15s; +} + +.radar-item-title:hover { + color: rgba(255, 170, 0, 0.9); +} + +.radar-item-meta { + display: flex; + gap: 1rem; + margin-top: 0.3rem; +} + +.radar-item-domain { + font-family: 'JetBrains Mono', monospace; + font-size: 0.5rem; + color: rgba(255, 255, 255, 0.15); + letter-spacing: 0.5px; +} + +.radar-item-comments { + font-family: 'JetBrains Mono', monospace; + font-size: 0.5rem; + color: rgba(255, 170, 0, 0.35); + text-decoration: none; + letter-spacing: 0.5px; +} + +.radar-item-comments:hover { + color: rgba(255, 170, 0, 0.7); +} + +.radar-empty { + font-family: 'JetBrains Mono', monospace; + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.2); + text-align: center; + padding: 3rem; + letter-spacing: 2px; +} + +/* ─── Responsive ───────────────────────────────── */ +@media (max-width: 768px) { + .radar-controls { + flex-direction: column; + } + .radar-actions { + width: 100%; + } + .radar-search { + flex: 1; + } + .radar-item { + flex-wrap: wrap; + gap: 0.4rem; + } + .radar-item-time { + min-width: auto; + } + .radar-item-source { + min-width: auto; + } + .radar-stats { + gap: 0.8rem; + } +} + +@media (max-width: 480px) { + .radar-container { + padding: 0 1rem 2rem; + } + .radar-filters { + gap: 0.3rem; + } + .radar-filter { + font-size: 0.5rem; + padding: 0.3rem 0.5rem; + } +} diff --git a/js/radar.js b/js/radar.js new file mode 100644 index 0000000..993682c --- /dev/null +++ b/js/radar.js @@ -0,0 +1,169 @@ +/* ─── RADAR: Live Intelligence Feed ─────────────── */ +(function() { + 'use strict'; + + const API = '/api/radar'; + const REFRESH_INTERVAL = 15 * 60 * 1000; // 15 minutes + let currentSource = 'all'; + let allItems = []; + let refreshTimer = null; + + // ─── Time Ago ────────────────────────────────── + function timeAgo(dateStr) { + if (!dateStr) return ''; + const now = new Date(); + const then = new Date(dateStr); + const diff = Math.floor((now - then) / 1000); + if (diff < 60) return diff + 's ago'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + if (diff < 604800) return Math.floor(diff / 86400) + 'd ago'; + return Math.floor(diff / 604800) + 'w ago'; + } + + // ─── Extract Domain ──────────────────────────── + function extractDomain(url) { + try { + const u = new URL(url); + return u.hostname.replace('www.', ''); + } catch(e) { + return ''; + } + } + + // ─── Escape HTML ─────────────────────────────── + function esc(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } + + // ─── Render Feed ─────────────────────────────── + function renderFeed(items) { + const feed = document.getElementById('radarFeed'); + if (!items || items.length === 0) { + feed.innerHTML = '
> Latest cutting-edge tools, tech, and developments detected on the wire.
+> Live intelligence feed — intercepted from Hacker News, Reddit, and Lobste.rs