|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | + <meta charset="UTF-8" /> |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | + <title>DevPulse Demo</title> |
| 7 | + <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>"> |
| 8 | + <style> |
| 9 | + :root { |
| 10 | + --color-primary: #f97316; |
| 11 | + --color-primary-light: #fff7ed; |
| 12 | + --color-primary-dark: #c2410c; |
| 13 | + --color-primary-hover: #ea580c; |
| 14 | + --color-secondary: #6366f1; |
| 15 | + --color-secondary-light: #eef2ff; |
| 16 | + --color-accent: #10b981; |
| 17 | + --color-accent-light: #ecfdf5; |
| 18 | + --color-error: #ef4444; |
| 19 | + --color-error-bg: #fef2f2; |
| 20 | + --color-bg: #fffbf7; |
| 21 | + --color-surface: #ffffff; |
| 22 | + --color-border: #f0e6db; |
| 23 | + --color-border-light: #f7f0e8; |
| 24 | + --color-text: #292017; |
| 25 | + --color-text-secondary: #78716c; |
| 26 | + --color-text-muted: #a8a29e; |
| 27 | + --shadow-md: 0 2px 8px rgba(41, 32, 23, 0.08); |
| 28 | + } |
| 29 | + |
| 30 | + *, *::before, *::after { box-sizing: border-box; margin: 0; } |
| 31 | + |
| 32 | + body { |
| 33 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans SC', sans-serif; |
| 34 | + font-size: 14px; line-height: 1.5; |
| 35 | + color: var(--color-text); background: var(--color-bg); |
| 36 | + -webkit-font-smoothing: antialiased; |
| 37 | + } |
| 38 | + |
| 39 | + a { color: var(--color-primary); text-decoration: none; } |
| 40 | + |
| 41 | + /* Demo banner */ |
| 42 | + .demo-banner { |
| 43 | + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); |
| 44 | + color: white; text-align: center; padding: 12px 16px; font-size: 13px; |
| 45 | + } |
| 46 | + .demo-banner a { color: white; text-decoration: underline; font-weight: 600; } |
| 47 | + |
| 48 | + /* Feed */ |
| 49 | + .feed { max-width: 800px; margin: 0 auto; background: var(--color-surface); min-height: 100vh; box-shadow: var(--shadow-md); } |
| 50 | + .feed-header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid var(--color-border); } |
| 51 | + .feed-header h1 { font-size: 20px; } |
| 52 | + .logo-icon { color: var(--color-primary); } |
| 53 | + .feed-actions { display: flex; align-items: center; gap: 12px; font-size: 13px; } |
| 54 | + .feed-actions button, .feed-actions select { |
| 55 | + padding: 6px 14px; cursor: pointer; border: 1px solid var(--color-border); |
| 56 | + border-radius: 6px; background: var(--color-surface); color: var(--color-text); |
| 57 | + font-family: inherit; font-size: inherit; transition: all 0.15s; |
| 58 | + } |
| 59 | + .feed-actions button:hover { background: var(--color-primary-light); border-color: var(--color-primary); color: var(--color-primary); } |
| 60 | + .feed-actions a { color: var(--color-secondary); font-weight: 500; } |
| 61 | + |
| 62 | + /* Site filter */ |
| 63 | + .site-filter { display: flex; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--color-border-light); flex-wrap: wrap; } |
| 64 | + .site-filter button { |
| 65 | + padding: 5px 14px; border: 1px solid var(--color-border); border-radius: 16px; |
| 66 | + background: var(--color-surface); cursor: pointer; font-size: 13px; |
| 67 | + color: var(--color-text-secondary); font-family: inherit; transition: all 0.15s; |
| 68 | + } |
| 69 | + .site-filter button:hover:not(.active) { background: var(--color-primary-light); border-color: var(--color-primary); color: var(--color-primary); } |
| 70 | + .site-filter button.active { background: var(--color-primary); color: white; border-color: var(--color-primary); font-weight: 500; } |
| 71 | + |
| 72 | + /* Post item */ |
| 73 | + .post-item { |
| 74 | + padding: 14px 16px; border-bottom: 1px solid var(--color-border-light); |
| 75 | + cursor: pointer; transition: all 0.15s; |
| 76 | + } |
| 77 | + .post-item:hover { background: var(--color-primary-light); } |
| 78 | + .post-item.read { opacity: 0.6; background: var(--color-bg); } |
| 79 | + .post-item.read .post-title { color: var(--color-text-secondary); } |
| 80 | + .post-title { margin: 0 0 5px; font-size: 15px; font-weight: 500; line-height: 1.4; } |
| 81 | + .post-meta { font-size: 12px; color: var(--color-text-secondary); display: flex; gap: 12px; align-items: center; } |
| 82 | + .post-source { |
| 83 | + color: var(--color-secondary); font-weight: 600; |
| 84 | + background: var(--color-secondary-light); padding: 1px 8px; |
| 85 | + border-radius: 10px; font-size: 11px; |
| 86 | + } |
| 87 | + .post-score { color: var(--color-primary); font-weight: 600; } |
| 88 | + .post-author, .post-time { color: var(--color-text-muted); } |
| 89 | + .post-reason { |
| 90 | + font-size: 13px; color: var(--color-text-secondary); |
| 91 | + margin: 6px 0 0; padding-left: 8px; border-left: 2px solid var(--color-accent); |
| 92 | + } |
| 93 | + |
| 94 | + /* Hiding animation */ |
| 95 | + .post-hiding { animation: slideOut 0.4s ease-out forwards; } |
| 96 | + @keyframes slideOut { |
| 97 | + 0% { opacity: 0.5; transform: translateX(0); } |
| 98 | + 50% { opacity: 0.2; transform: translateX(30px); } |
| 99 | + 100% { opacity: 0; height: 0; padding: 0; margin: 0; border: none; transform: translateX(60px); overflow: hidden; } |
| 100 | + } |
| 101 | + |
| 102 | + /* Mobile */ |
| 103 | + @media (max-width: 640px) { |
| 104 | + .feed-header { flex-wrap: wrap; gap: 8px; } |
| 105 | + .feed-actions { flex-wrap: wrap; } |
| 106 | + .demo-banner { font-size: 12px; } |
| 107 | + } |
| 108 | + </style> |
| 109 | +</head> |
| 110 | +<body> |
| 111 | + <div class="demo-banner"> |
| 112 | + This is a live demo with sample data. |
| 113 | + <a href="https://github.com/polunzh/devpulse">View on GitHub</a> | |
| 114 | + <a href="https://github.com/polunzh/devpulse#quick-start">Deploy your own</a> |
| 115 | + </div> |
| 116 | + |
| 117 | + <div class="feed" id="app"> |
| 118 | + <header class="feed-header"> |
| 119 | + <h1><span class="logo-icon">⚡</span> DevPulse</h1> |
| 120 | + <div class="feed-actions"> |
| 121 | + <label><input type="checkbox" id="hideRead"> Hide read</label> |
| 122 | + <select id="sortBy"> |
| 123 | + <option value="score">By relevance</option> |
| 124 | + <option value="time">By time</option> |
| 125 | + </select> |
| 126 | + <button onclick="refreshPosts()">Refresh</button> |
| 127 | + <a href="#settings" onclick="alert('Settings page available in full deployment')">Settings</a> |
| 128 | + </div> |
| 129 | + </header> |
| 130 | + |
| 131 | + <div class="site-filter" id="siteFilter"></div> |
| 132 | + <div id="postList"></div> |
| 133 | + </div> |
| 134 | + |
| 135 | + <script> |
| 136 | + const MOCK_SITES = [ |
| 137 | + { id: 's1', name: 'Hacker News' }, |
| 138 | + { id: 's2', name: 'Reddit/programming' }, |
| 139 | + { id: 's3', name: 'Reddit/rust' }, |
| 140 | + { id: 's4', name: 'V2EX' }, |
| 141 | + ]; |
| 142 | + |
| 143 | + const MOCK_POSTS = [ |
| 144 | + { id: '1', siteId: 's1', title: 'Show HN: I built a real-time collaborative code editor in Rust', url: '#', author: 'rustdev', score: 842, aiScore: 0.95, aiReason: 'Rust 项目,与你的兴趣高度匹配', publishedAt: new Date(Date.now() - 2*3600000).toISOString() }, |
| 145 | + { id: '2', siteId: 's1', title: 'The State of AI in 2026: What Changed', url: '#', author: 'mlresearcher', score: 651, aiScore: 0.92, aiReason: 'AI 领域深度分析,推荐阅读', publishedAt: new Date(Date.now() - 3*3600000).toISOString() }, |
| 146 | + { id: '3', siteId: 's2', title: 'Why we migrated from microservices back to a monolith', url: '#', author: 'sre_engineer', score: 523, aiScore: 0.88, aiReason: '系统设计实践经验,架构决策参考', publishedAt: new Date(Date.now() - 4*3600000).toISOString() }, |
| 147 | + { id: '4', siteId: 's1', title: 'PostgreSQL 18 Released with Major Performance Improvements', url: '#', author: 'pgfan', score: 489, aiScore: 0.75, aiReason: '数据库重大更新,值得关注', publishedAt: new Date(Date.now() - 5*3600000).toISOString() }, |
| 148 | + { id: '5', siteId: 's3', title: 'Announcing Tokio 2.0: The Async Runtime Gets a Major Overhaul', url: '#', author: 'tokio_team', score: 378, aiScore: 0.93, aiReason: 'Rust 异步运行时核心更新', publishedAt: new Date(Date.now() - 1*3600000).toISOString() }, |
| 149 | + { id: '6', siteId: 's2', title: 'How Discord stores trillions of messages', url: '#', author: 'discord_eng', score: 412, aiScore: 0.85, aiReason: '大规模系统设计案例', publishedAt: new Date(Date.now() - 6*3600000).toISOString() }, |
| 150 | + { id: '7', siteId: 's4', title: '分享一个我做的开源项目,用了半年时间', url: '#', author: 'v2exer', score: 156, aiScore: 0.65, aiReason: '开源项目分享,社区讨论', publishedAt: new Date(Date.now() - 7*3600000).toISOString() }, |
| 151 | + { id: '8', siteId: 's1', title: 'CSS just got container queries and they change everything', url: '#', author: 'csswizard', score: 298, aiScore: 0.45, aiReason: '前端技术更新', publishedAt: new Date(Date.now() - 8*3600000).toISOString() }, |
| 152 | + { id: '9', siteId: 's3', title: 'Writing a compiler in Rust: A practical guide', url: '#', author: 'compiler_nerd', score: 267, aiScore: 0.91, aiReason: 'Rust 编译器实践,技术深度高', publishedAt: new Date(Date.now() - 2.5*3600000).toISOString() }, |
| 153 | + { id: '10', siteId: 's4', title: '最近 AI 编程工具测评,Claude Code vs Cursor', url: '#', author: 'techblogger', score: 203, aiScore: 0.78, aiReason: 'AI 工具对比,实用参考', publishedAt: new Date(Date.now() - 9*3600000).toISOString() }, |
| 154 | + { id: '11', siteId: 's2', title: 'TIL: You can use SQLite as a message queue', url: '#', author: 'pragmatic_dev', score: 341, aiScore: 0.72, aiReason: 'SQLite 创新用法', publishedAt: new Date(Date.now() - 10*3600000).toISOString() }, |
| 155 | + { id: '12', siteId: 's1', title: 'The smallest Go binary ever: 14 bytes', url: '#', author: 'gopher', score: 189, aiScore: 0.55, aiReason: '有趣的技术挑战', publishedAt: new Date(Date.now() - 12*3600000).toISOString() }, |
| 156 | + ]; |
| 157 | + |
| 158 | + let activeSiteId = null; |
| 159 | + let readIds = new Set(); |
| 160 | + let currentSort = 'score'; |
| 161 | + |
| 162 | + function timeAgo(dateStr) { |
| 163 | + const diff = Date.now() - new Date(dateStr).getTime(); |
| 164 | + const hours = Math.floor(diff / 3600000); |
| 165 | + if (hours < 1) return 'just now'; |
| 166 | + if (hours < 24) return hours + 'h ago'; |
| 167 | + return Math.floor(hours / 24) + 'd ago'; |
| 168 | + } |
| 169 | + |
| 170 | + function renderSiteFilter() { |
| 171 | + const el = document.getElementById('siteFilter'); |
| 172 | + let html = `<button class="${!activeSiteId ? 'active' : ''}" onclick="filterSite(null)">All</button>`; |
| 173 | + MOCK_SITES.forEach(s => { |
| 174 | + html += `<button class="${activeSiteId === s.id ? 'active' : ''}" onclick="filterSite('${s.id}')">${s.name}</button>`; |
| 175 | + }); |
| 176 | + el.innerHTML = html; |
| 177 | + } |
| 178 | + |
| 179 | + function getSortedPosts() { |
| 180 | + let posts = [...MOCK_POSTS]; |
| 181 | + if (activeSiteId) posts = posts.filter(p => p.siteId === activeSiteId); |
| 182 | + |
| 183 | + const hideRead = document.getElementById('hideRead').checked; |
| 184 | + if (hideRead) posts = posts.filter(p => !readIds.has(p.id)); |
| 185 | + |
| 186 | + if (currentSort === 'score') { |
| 187 | + posts.sort((a, b) => { |
| 188 | + const aFinal = (a.score / 842) * 0.4 + a.aiScore * 0.6; |
| 189 | + const bFinal = (b.score / 842) * 0.4 + b.aiScore * 0.6; |
| 190 | + return bFinal - aFinal; |
| 191 | + }); |
| 192 | + } else { |
| 193 | + posts.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)); |
| 194 | + } |
| 195 | + return posts; |
| 196 | + } |
| 197 | + |
| 198 | + function renderPosts() { |
| 199 | + const posts = getSortedPosts(); |
| 200 | + const el = document.getElementById('postList'); |
| 201 | + if (posts.length === 0) { |
| 202 | + el.innerHTML = '<p style="text-align:center;color:var(--color-text-muted);padding:60px 20px;">No posts to show.</p>'; |
| 203 | + return; |
| 204 | + } |
| 205 | + const siteName = (siteId) => MOCK_SITES.find(s => s.id === siteId)?.name || ''; |
| 206 | + el.innerHTML = posts.map(p => ` |
| 207 | + <div class="post-item ${readIds.has(p.id) ? 'read' : ''}" id="post-${p.id}" onclick="markRead('${p.id}')"> |
| 208 | + <h3 class="post-title">${p.title}</h3> |
| 209 | + <div class="post-meta"> |
| 210 | + <span class="post-source">${siteName(p.siteId)}</span> |
| 211 | + <span class="post-score">▲ ${p.score}</span> |
| 212 | + <span class="post-author">by ${p.author}</span> |
| 213 | + <span class="post-time">${timeAgo(p.publishedAt)}</span> |
| 214 | + </div> |
| 215 | + ${p.aiReason ? `<p class="post-reason">${p.aiReason}</p>` : ''} |
| 216 | + </div> |
| 217 | + `).join(''); |
| 218 | + } |
| 219 | + |
| 220 | + function filterSite(id) { |
| 221 | + activeSiteId = id; |
| 222 | + renderSiteFilter(); |
| 223 | + renderPosts(); |
| 224 | + } |
| 225 | + |
| 226 | + function markRead(id) { |
| 227 | + readIds.add(id); |
| 228 | + const el = document.getElementById('post-' + id); |
| 229 | + if (el) el.classList.add('read'); |
| 230 | + |
| 231 | + if (document.getElementById('hideRead').checked) { |
| 232 | + setTimeout(() => { |
| 233 | + if (el) el.classList.add('post-hiding'); |
| 234 | + setTimeout(() => renderPosts(), 400); |
| 235 | + }, 800); |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + function refreshPosts() { |
| 240 | + const btn = event.target; |
| 241 | + btn.textContent = 'Fetching...'; |
| 242 | + btn.disabled = true; |
| 243 | + setTimeout(() => { |
| 244 | + btn.textContent = 'Refresh'; |
| 245 | + btn.disabled = false; |
| 246 | + }, 1500); |
| 247 | + } |
| 248 | + |
| 249 | + document.getElementById('sortBy').addEventListener('change', (e) => { |
| 250 | + currentSort = e.target.value; |
| 251 | + renderPosts(); |
| 252 | + }); |
| 253 | + document.getElementById('hideRead').addEventListener('change', () => renderPosts()); |
| 254 | + |
| 255 | + renderSiteFilter(); |
| 256 | + renderPosts(); |
| 257 | + </script> |
| 258 | +</body> |
| 259 | +</html> |
0 commit comments