[READ-ONLY] Mirror of https://github.com/aaronateataco/robloxbanwave. Real-time Roblox ban wave tracker powered by community reports from r/robloxhackers. robloxbanwave.vercel.app
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: enhance voting system with per-post vote buttons and improved vote handling

Fascinating Pistachio (May 27, 2026, 2:34 PM +0100) fa8fcb58 ec1604ec

+134 -120
+4 -1
api/contact.js
··· 17 17 if (msg.length > 1800) return res.status(400).json({ error: 'Message too long (max 1800 characters).' }); 18 18 const cat = VALID_CATEGORIES.includes(String(category || '').toLowerCase()) ? String(category).toLowerCase() : 'general'; 19 19 20 - const webhookUrl = process.env.CONTACT_WEBHOOK_URL; 20 + let webhookUrl = process.env.CONTACT_WEBHOOK_URL; 21 + if (!webhookUrl) { 22 + try { webhookUrl = await kv.get('config:contact:webhook'); } catch { /* non-fatal */ } 23 + } 21 24 if (!webhookUrl) { 22 25 return res.status(503).json({ error: 'Contact form not configured yet - please open a GitHub issue instead.' }); 23 26 }
+25 -15
api/vote.js
··· 1 1 import { kv } from '@vercel/kv'; 2 2 import { createHash } from 'node:crypto'; 3 3 4 + const POST_ID_RE = /^[a-z0-9_]{1,20}$/i; 5 + 4 6 function kvError(e) { 5 7 return /KV_|UPSTASH|fetch failed/i.test(e?.message ?? ''); 6 8 } ··· 11 13 12 14 export default async function handler(req, res) { 13 15 res.setHeader('Content-Type', 'application/json'); 14 - const d = todayKey(); 15 16 17 + // GET /api/vote?posts=id1,id2,... - returns vote counts for listed post IDs 16 18 if (req.method === 'GET') { 19 + const postIds = String(req.query.posts || '') 20 + .split(',').map(s => s.trim()).filter(s => POST_ID_RE.test(s)).slice(0, 50); 21 + if (!postIds.length) return res.json({ votes: {} }); 17 22 try { 18 - const [up, down] = await Promise.all([ 19 - kv.get(`vote:up:${d}`), 20 - kv.get(`vote:down:${d}`), 21 - ]); 23 + const pairs = await Promise.all(postIds.map(id => 24 + Promise.all([kv.get(`vote:post:${id}:up`), kv.get(`vote:post:${id}:down`)]) 25 + .then(([up, down]) => [id, { up: Number(up || 0), down: Number(down || 0) }]) 26 + )); 22 27 res.setHeader('Cache-Control', 's-maxage=15, stale-while-revalidate=30'); 23 - return res.json({ up: Number(up || 0), down: Number(down || 0), date: d }); 28 + return res.json({ votes: Object.fromEntries(pairs) }); 24 29 } catch (e) { 25 - if (kvError(e)) return res.status(503).json({ error: 'KV not configured', up: 0, down: 0 }); 26 - return res.status(500).json({ error: e.message, up: 0, down: 0 }); 30 + if (kvError(e)) return res.status(503).json({ error: 'KV not configured', votes: {} }); 31 + return res.status(500).json({ error: e.message, votes: {} }); 27 32 } 28 33 } 29 34 35 + // POST /api/vote body: { postId, type: "up"|"down" } 30 36 if (req.method === 'POST') { 31 - const { type } = req.body ?? {}; 37 + const { postId, type } = req.body ?? {}; 38 + if (!postId || !POST_ID_RE.test(String(postId))) { 39 + return res.status(400).json({ error: 'Invalid postId.' }); 40 + } 32 41 if (type !== 'up' && type !== 'down') { 33 42 return res.status(400).json({ error: 'type must be "up" or "down".' }); 34 43 } 35 44 45 + const d = todayKey(); 36 46 const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || 'unknown'; 37 47 const ipHash = createHash('sha256').update(ip).digest('hex').slice(0, 20); 38 48 try { 39 - const rlKey = `rl:vote:${ipHash}:${d}`; 49 + const rlKey = `rl:vote:${ipHash}:${postId}:${d}`; 40 50 const cnt = await kv.incr(rlKey); 41 51 if (cnt === 1) await kv.expire(rlKey, 86400); 42 - if (cnt > 1) return res.status(429).json({ error: 'You have already voted today.' }); 52 + if (cnt > 1) return res.status(429).json({ error: 'Already voted on this post today.' }); 43 53 } catch (e) { 44 54 if (kvError(e)) return res.status(503).json({ error: 'KV not configured.' }); 45 55 } 46 56 47 57 try { 48 - const val = await kv.incr(`vote:${type}:${d}`); 49 - await kv.expire(`vote:${type}:${d}`, 9 * 24 * 3600); 50 - const other = Number(await kv.get(`vote:${type === 'up' ? 'down' : 'up'}:${d}`) || 0); 58 + const val = await kv.incr(`vote:post:${postId}:${type}`); 59 + await kv.expire(`vote:post:${postId}:${type}`, 30 * 24 * 3600); 60 + const other = Number(await kv.get(`vote:post:${postId}:${type === 'up' ? 'down' : 'up'}`) || 0); 51 61 return res.status(201).json({ 52 62 up: type === 'up' ? val : other, 53 63 down: type === 'down' ? val : other, 54 64 voted: type, 55 - date: d, 65 + postId, 56 66 }); 57 67 } catch (e) { 58 68 if (kvError(e)) return res.status(503).json({ error: 'KV not configured.' });
+105 -104
index.html
··· 311 311 .hdr-report-btn:hover{background:rgba(239,68,68,.16);border-color:rgba(239,68,68,.42)} 312 312 .hdr-report-btn svg{width:12px;height:12px;flex-shrink:0} 313 313 314 - /* Vote widget */ 315 - .vote-wrap{max-width:720px;margin:0 auto;padding:0 20px 32px} 316 - .vote-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px 20px;display:flex;align-items:center;gap:16px;flex-wrap:wrap} 317 - .vote-label{flex:1;min-width:160px} 318 - .vote-label strong{font-size:13px;color:var(--text);display:block;margin-bottom:2px} 319 - .vote-label span{font-size:11px;color:var(--text2)} 320 - .vote-btns{display:flex;gap:8px;align-items:center} 321 - .vote-btn{background:var(--surface);border:1px solid var(--border);color:var(--text2);padding:6px 16px;border-radius:8px;font-family:var(--mono);font-size:11px;font-weight:700;cursor:pointer;transition:all var(--t);display:flex;align-items:center;gap:6px} 322 - .vote-btn:hover:not(:disabled){background:var(--surface-h);border-color:var(--border-h);color:var(--text)} 323 - .vote-btn.voted-up{background:rgba(34,197,94,.12);border-color:rgba(34,197,94,.4);color:#22c55e} 324 - .vote-btn.voted-down{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.35);color:#ef4444} 325 - .vote-btn:disabled{opacity:.55;cursor:not-allowed} 326 - .vote-tally{font-family:var(--mono);font-size:10px;color:var(--text3);text-align:center;min-width:60px} 314 + /* Per-post vote buttons (alert cards only) */ 315 + .pc-vote-row{display:flex;gap:5px;align-items:center;margin-top:7px;flex-wrap:wrap} 316 + .pc-vote-btn{background:var(--bg2);border:1px solid var(--border);color:var(--text3);padding:2px 8px;border-radius:5px;font-family:var(--mono);font-size:9px;font-weight:700;cursor:pointer;transition:all var(--t);display:inline-flex;align-items:center;gap:3px;line-height:1.6} 317 + .pc-vote-btn:hover:not(:disabled){background:var(--surface-h);border-color:var(--border-h);color:var(--text2)} 318 + .pc-vote-btn.voted-up{background:rgba(34,197,94,.1);border-color:rgba(34,197,94,.35);color:#22c55e} 319 + .pc-vote-btn.voted-down{background:rgba(239,68,68,.08);border-color:rgba(239,68,68,.3);color:#ef4444} 320 + .pc-vote-btn:disabled{opacity:.55;cursor:not-allowed} 321 + .pc-vote-lbl{font-family:var(--mono);font-size:9px;color:var(--text3)} 327 322 328 323 /* Contact modal */ 329 324 .contact-textarea{width:100%;background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:10px 14px;border-radius:8px;font-family:var(--mono);font-size:12px;outline:none;resize:vertical;min-height:120px;transition:border-color var(--t);line-height:1.6} ··· 335 330 .contact-msg.err{background:rgba(239,68,68,.1);color:#ef4444;border:1px solid rgba(239,68,68,.25);display:block} 336 331 337 332 /* Footer */ 338 - footer{padding:32px 24px 28px;border-top:1px solid var(--border);font-family:var(--mono);font-size:10px;color:var(--text3)} 339 - .footer-inner{max-width:720px;margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:16px} 340 - .footer-pledges{display:flex;justify-content:center;flex-wrap:wrap;gap:10px 16px} 341 - .pledge{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--text3)} 342 - .footer-nav{display:flex;flex-wrap:wrap;justify-content:center;gap:6px 14px} 343 - .footer-nav a,.footer-nav button{color:var(--text3);text-decoration:none;background:none;border:none;cursor:pointer;font-family:var(--mono);font-size:10px;padding:0;transition:color var(--t)} 344 - .footer-nav a:hover,.footer-nav button:hover{color:var(--text2)} 345 - .footer-sep{color:var(--border-h)} 346 - .footer-github-btn{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--border);color:var(--text2);padding:7px 14px;border-radius:8px;font-family:var(--mono);font-size:11px;font-weight:700;text-decoration:none;transition:all var(--t)} 347 - .footer-github-btn:hover{background:var(--surface-h);border-color:var(--border-h);color:var(--text)} 348 - .footer-github-btn svg{width:14px;height:14px;flex-shrink:0} 349 - .footer-disclaimer{font-size:10px;color:var(--text3);text-align:center;line-height:1.7} 333 + footer{padding:18px 24px 14px;border-top:1px solid var(--border);font-family:var(--mono);font-size:10px;color:var(--text3)} 334 + .footer-inner{max-width:720px;margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:8px} 335 + .footer-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:center} 336 + .footer-pledges{display:flex;flex-wrap:wrap;justify-content:center;gap:4px 10px} 337 + .pledge{display:flex;align-items:center;gap:3px;font-size:9.5px;color:var(--text3)} 338 + .footer-nav{display:flex;flex-wrap:wrap;justify-content:center;gap:3px 8px;font-size:10px} 339 + .footer-nav a{color:var(--text3);text-decoration:none;transition:color var(--t)} 340 + .footer-nav a:hover{color:var(--text2)} 341 + .footer-sep{opacity:.3} 342 + .footer-gh-btn{display:inline-flex;align-items:center;gap:5px;background:var(--surface);border:1px solid var(--border);color:var(--text2);padding:4px 10px;border-radius:6px;font-family:var(--mono);font-size:10px;font-weight:700;text-decoration:none;transition:all var(--t);flex-shrink:0} 343 + .footer-gh-btn:hover{background:var(--surface-h);border-color:var(--border-h);color:var(--text)} 344 + .footer-gh-btn svg{width:12px;height:12px;flex-shrink:0} 345 + .footer-disclaimer{font-size:9px;color:var(--text3);text-align:center} 350 346 img.emoji{height:1em;width:1em;margin:0 .05em 0 .1em;vertical-align:-.1em} 351 347 352 348 @media(max-width:600px){ ··· 363 359 .report-card{flex-direction:column} 364 360 .ban-nudge{left:12px;right:12px;max-width:none;bottom:16px} 365 361 .hdr-report-txt{display:none} 366 - .vote-card{flex-direction:column;align-items:flex-start} 367 362 } 368 363 </style> 369 364 </head> ··· 439 434 <div class="posts" id="postsContainer"></div> 440 435 </div> 441 436 442 - <!-- Vote widget --> 443 - <div class="vote-wrap" id="voteSection"> 444 - <div class="vote-card"> 445 - <div class="vote-label"> 446 - <strong>Is there really a ban wave right now?</strong> 447 - <span>Upvote if there is an active wave, downvote if reports look like a false alarm.</span> 448 - </div> 449 - <div class="vote-btns"> 450 - <button class="vote-btn" id="voteUpBtn" onclick="submitVote('up')" title="Yes, there is a ban wave"> 451 - <span>&#128077;</span> <span id="voteUpCount">-</span> 452 - </button> 453 - <button class="vote-btn" id="voteDownBtn" onclick="submitVote('down')" title="No, this is a false alarm"> 454 - <span>&#128078;</span> <span id="voteDownCount">-</span> 455 - </button> 456 - </div> 457 - </div> 458 - </div> 459 - 460 437 <div class="sep" id="graphSep" style="display:none"><span>Banwave mention frequency</span></div> 461 438 <div class="graph-wrap" id="graphSection" style="display:none"> 462 439 <div class="graph-card" id="graphCard"></div> ··· 636 613 637 614 <footer> 638 615 <div class="footer-inner"> 639 - <div class="footer-pledges"> 640 - <span class="pledge">&#x1F6AB; No ads ever</span> 641 - <span class="pledge">&#x1F50D; No trackers ever</span> 642 - <span class="pledge">&#x1F193; Always free</span> 643 - <span class="pledge">&#x2764;&#xFE0F; Made with love in &#x1F1EC;&#x1F1E7; the UK</span> 616 + <div class="footer-top"> 617 + <div class="footer-pledges"> 618 + <span class="pledge">&#x1F6AB; No ads</span> 619 + <span class="pledge">&#x1F50D; No trackers</span> 620 + <span class="pledge">&#x1F193; Free forever</span> 621 + <span class="pledge">&#x2764;&#xFE0F; Made in &#x1F1EC;&#x1F1E7;</span> 622 + </div> 623 + <a class="footer-gh-btn" href="https://github.com/FascinatingPistachio/robloxbanwave" target="_blank" rel="noopener"> 624 + <svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg> 625 + &#x2B50; Star 626 + </a> 644 627 </div> 645 - <a class="footer-github-btn" href="https://github.com/FascinatingPistachio/robloxbanwave" target="_blank" rel="noopener"> 646 - <svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg> 647 - Star on GitHub 648 - </a> 649 628 <div class="footer-nav"> 650 - <a href="https://reddit.com/r/robloxhackers" target="_blank" rel="noopener">r/robloxhackers</a> 651 - <span class="footer-sep">·</span> 652 - <a href="/api/rss">RSS Feed</a> 653 - <span class="footer-sep">·</span> 654 - <a href="/privacy" onclick="openLegal('privacy');return false;">Privacy Notice</a> 655 - <span class="footer-sep">·</span> 656 - <a href="/terms" onclick="openLegal('terms');return false;">Terms of Use</a> 657 - <span class="footer-sep">·</span> 658 - <a href="/contact" onclick="openContact();return false;">Contact</a> 659 - <span class="footer-sep">·</span> 660 - <a href="https://github.com/FascinatingPistachio/robloxbanwave/issues" target="_blank" rel="noopener">Bug Reports</a> 629 + <a href="https://reddit.com/r/robloxhackers" target="_blank" rel="noopener">r/robloxhackers</a><span class="footer-sep"> · </span><a href="/api/rss">RSS</a><span class="footer-sep"> · </span><a href="/privacy" onclick="openLegal('privacy');return false;">Privacy</a><span class="footer-sep"> · </span><a href="/terms" onclick="openLegal('terms');return false;">Terms</a><span class="footer-sep"> · </span><a href="/contact" onclick="openContact();return false;">Contact</a><span class="footer-sep"> · </span><a href="https://github.com/FascinatingPistachio/robloxbanwave/issues" target="_blank" rel="noopener">Bug Reports</a> 661 630 </div> 662 - <div class="footer-disclaimer">Community tracker &mdash; not affiliated with Roblox Corporation. Data may be delayed or inaccurate.</div> 631 + <div class="footer-disclaimer">Community tracker &mdash; not affiliated with Roblox Corporation</div> 663 632 </div> 664 633 </footer> 665 634 ··· 1012 981 1013 982 // -- Hero ---------------------------------------------------------------------- 1014 983 function renderHero(posts, totalScore, source, fetchedAt) { 1015 - const recent = posts[0] || null; 984 + const recent = posts.find(p=>p._type==='alert') || posts.find(p=>p._type!=='meta') || posts[0] || null; 1016 985 let status, kicker, headline, desc; 1017 986 1018 987 if (totalScore >= SCORE_ACTIVE) { ··· 1096 1065 : p._type==='question' ? '<span class="tag tag-question">Question</span>' 1097 1066 : p._type==='meta' ? '<span class="tag tag-info">Info</span>' : ''; 1098 1067 const vBadge = (i===0 && verifiedCount>0) 1099 - ? `<span class="tag tag-verified" title="Community-verified bans today">🛡 ${verifiedCount} verified today</span>` 1068 + ? `<span class="tag tag-verified" title="Community-verified bans today">&#x1F6E1; ${verifiedCount} verified today</span>` 1069 + : ''; 1070 + const pid = esc(p.id||''); 1071 + const voteRow = p._type==='alert' && pid 1072 + ? `<div class="pc-vote-row"><button class="pc-vote-btn" id="pv-up-${pid}" onclick="votePost(event,'${pid}','up')">&#128077; <span id="pvc-up-${pid}">-</span></button><button class="pc-vote-btn" id="pv-down-${pid}" onclick="votePost(event,'${pid}','down')">&#128078; <span id="pvc-down-${pid}">-</span></button><span class="pc-vote-lbl">Active ban wave?</span></div>` 1100 1073 : ''; 1101 1074 return ` 1102 1075 <a class="post-card" data-type="${esc(p._type)}" href="${esc(p._url)}" target="_blank" rel="noopener"> ··· 1104 1077 <div class="pc-body"> 1105 1078 <div class="pc-title">${esc(p.title)}</div> 1106 1079 <div class="pc-meta">${tagHtml}${vBadge}<span>${timeAgo(p.created_utc)}</span></div> 1080 + ${voteRow} 1107 1081 </div> 1108 - <div class="pc-link-icon">↗</div> 1082 + <div class="pc-link-icon">&#8599;</div> 1109 1083 </a>`; 1110 1084 }).join(''); 1111 1085 } ··· 1115 1089 document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active')); 1116 1090 btn.classList.add('active'); 1117 1091 renderFeed(window._verifiedCount||0); 1092 + loadPostVotes(); 1118 1093 } 1119 1094 1120 1095 // -- Graph --------------------------------------------------------------------- ··· 1505 1480 } finally { btn.disabled=false; btn.textContent='Submit Report'; } 1506 1481 } 1507 1482 1508 - // -- Vote widget ---------------------------------------------------------------- 1509 - const LS_VOTE = 'rbx_voted'; 1510 - async function loadVotes() { 1483 + // -- Per-post votes ------------------------------------------------------------ 1484 + const LS_VOTED_POSTS = 'rbx_voted_posts'; 1485 + 1486 + function getVotedPosts() { 1487 + try { return JSON.parse(localStorage.getItem(LS_VOTED_POSTS) || '{}'); } catch { return {}; } 1488 + } 1489 + 1490 + async function loadPostVotes() { 1491 + const alertPosts = allPosts.filter(p => p._type === 'alert' && p.id); 1492 + if (!alertPosts.length) return; 1493 + const voted = getVotedPosts(); 1494 + for (const p of alertPosts) { 1495 + const v = voted[p.id]; 1496 + if (!v) continue; 1497 + const up = document.getElementById(`pv-up-${p.id}`); 1498 + const down = document.getElementById(`pv-down-${p.id}`); 1499 + if (up) { up.disabled = true; up.classList.toggle('voted-up', v === 'up'); } 1500 + if (down) { down.disabled = true; down.classList.toggle('voted-down', v === 'down'); } 1501 + } 1511 1502 try { 1512 - const r = await fetch('/api/vote'); 1503 + const ids = alertPosts.map(p => p.id).join(','); 1504 + const r = await fetch(`/api/vote?posts=${encodeURIComponent(ids)}`); 1513 1505 if (!r.ok) return; 1514 - const d = await r.json(); 1515 - document.getElementById('voteUpCount').textContent = d.up || 0; 1516 - document.getElementById('voteDownCount').textContent = d.down || 0; 1506 + const { votes } = await r.json(); 1507 + for (const [id, counts] of Object.entries(votes || {})) { 1508 + const uel = document.getElementById(`pvc-up-${id}`); 1509 + const del = document.getElementById(`pvc-down-${id}`); 1510 + if (uel) uel.textContent = counts.up ?? 0; 1511 + if (del) del.textContent = counts.down ?? 0; 1512 + } 1517 1513 } catch {} 1518 - const voted = localStorage.getItem(LS_VOTE); 1519 - if (voted) { 1520 - document.getElementById('voteUpBtn').disabled = true; 1521 - document.getElementById('voteDownBtn').disabled = true; 1522 - document.getElementById('voteUpBtn').classList.toggle('voted-up', voted === 'up'); 1523 - document.getElementById('voteDownBtn').classList.toggle('voted-down', voted === 'down'); 1524 - } 1525 1514 } 1526 - async function submitVote(type) { 1527 - if (localStorage.getItem(LS_VOTE)) { showToast('ℹ️', 'You already voted today.'); return; } 1528 - document.getElementById('voteUpBtn').disabled = true; 1529 - document.getElementById('voteDownBtn').disabled = true; 1515 + 1516 + async function votePost(e, postId, type) { 1517 + e.preventDefault(); 1518 + e.stopPropagation(); 1519 + const voted = getVotedPosts(); 1520 + if (voted[postId]) { showToast('&#x2139;&#xFE0F;', 'Already voted on this post.'); return; } 1521 + const upBtn = document.getElementById(`pv-up-${postId}`); 1522 + const downBtn = document.getElementById(`pv-down-${postId}`); 1523 + if (upBtn) upBtn.disabled = true; 1524 + if (downBtn) downBtn.disabled = true; 1530 1525 try { 1531 - const res = await fetch('/api/vote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type }) }); 1526 + const res = await fetch('/api/vote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ postId, type }) }); 1532 1527 const data = await res.json(); 1533 1528 if (res.ok || res.status === 201) { 1534 - localStorage.setItem(LS_VOTE, type); 1535 - document.getElementById('voteUpCount').textContent = data.up || 0; 1536 - document.getElementById('voteDownCount').textContent = data.down || 0; 1537 - document.getElementById('voteUpBtn').classList.toggle('voted-up', type === 'up'); 1538 - document.getElementById('voteDownBtn').classList.toggle('voted-down', type === 'down'); 1539 - showToast(type === 'up' ? '👍' : '👎', type === 'up' ? 'Thanks! Upvoted.' : 'Thanks! Downvoted.'); 1529 + voted[postId] = type; 1530 + localStorage.setItem(LS_VOTED_POSTS, JSON.stringify(voted)); 1531 + if (upBtn) { upBtn.classList.toggle('voted-up', type === 'up'); const c = document.getElementById(`pvc-up-${postId}`); if (c) c.textContent = data.up ?? 0; } 1532 + if (downBtn) { downBtn.classList.toggle('voted-down', type === 'down'); const c = document.getElementById(`pvc-down-${postId}`); if (c) c.textContent = data.down ?? 0; } 1533 + showToast(type === 'up' ? '&#128077;' : '&#128078;', type === 'up' ? 'Upvoted - active wave.' : 'Downvoted - false alarm?'); 1540 1534 } else if (res.status === 429) { 1541 - localStorage.setItem(LS_VOTE, type); 1542 - showToast('ℹ️', 'You already voted today.'); 1535 + voted[postId] = type; 1536 + localStorage.setItem(LS_VOTED_POSTS, JSON.stringify(voted)); 1537 + showToast('&#x2139;&#xFE0F;', data.error || 'Already voted.'); 1543 1538 } else { 1544 - document.getElementById('voteUpBtn').disabled = false; 1545 - document.getElementById('voteDownBtn').disabled = false; 1546 - showToast('❌', data.error || 'Vote failed.'); 1539 + if (upBtn) upBtn.disabled = false; 1540 + if (downBtn) downBtn.disabled = false; 1541 + showToast('&#x274C;', data.error || 'Vote failed.'); 1547 1542 } 1548 - } catch (e) { 1549 - document.getElementById('voteUpBtn').disabled = false; 1550 - document.getElementById('voteDownBtn').disabled = false; 1551 - showToast('❌', 'Network error: ' + e.message); 1543 + } catch (err) { 1544 + if (upBtn) upBtn.disabled = false; 1545 + if (downBtn) downBtn.disabled = false; 1546 + showToast('&#x274C;', 'Network error: ' + err.message); 1552 1547 } 1553 1548 } 1554 1549 ··· 1566 1561 document.getElementById('graphSection').style.display='none'; 1567 1562 1568 1563 try { 1569 - const [{posts,source,fetchedAt}, verifiedCount] = await Promise.all([fetchPosts(), loadVerifiedCount(), loadVotes()]); 1564 + const [{posts,source,fetchedAt}, verifiedCount] = await Promise.all([fetchPosts(), loadVerifiedCount()]); 1570 1565 1571 1566 allPosts = posts.map(p=>({ 1572 1567 ...p, 1573 1568 _type: classify(p.title||''), 1574 1569 _url: (()=>{ const rp=(p.permalink||'').replace(/['"<>]/g,''); return rp.startsWith('http')?rp:'https://reddit.com'+rp; })(), 1575 - })).sort((a,b)=>b.created_utc-a.created_utc); 1570 + })).sort((a,b)=>{ 1571 + const ar=a._type==='alert'?0:a._type==='meta'?2:1; 1572 + const br=b._type==='alert'?0:b._type==='meta'?2:1; 1573 + if(ar!==br)return ar-br; 1574 + return b.created_utc-a.created_utc; 1575 + }); 1576 1576 1577 1577 const totalScore=allPosts.reduce((s,p)=>s+scorePost(p),0); 1578 1578 renderHero(allPosts,totalScore,source,fetchedAt); ··· 1582 1582 document.getElementById('feedSection').style.display='block'; 1583 1583 renderFeed(verifiedCount); 1584 1584 renderGraph(allPosts); 1585 + loadPostVotes(); 1585 1586 } 1586 1587 1587 1588 document.getElementById('lastCheck').textContent='Checked '+new Date().toLocaleTimeString();