|
| 1 | +// contributors.js |
| 2 | +// Minimal, dependency-free module to fetch & render contributors for commitra/vanilla-verse |
| 3 | +// Place alongside index.html and styles.css and open index.html in a browser. |
| 4 | +// --- Notes --- |
| 5 | +// To avoid GitHub API rate limits for unauthenticated requests, provide a token: |
| 6 | +// 1) Create a personal access token with "public_repo" (no scopes needed for public data). |
| 7 | +// 2) Paste it below as `GITHUB_TOKEN` or set in localStorage under key "GH_TOKEN". |
| 8 | +// Keep tokens private — don't commit them to public repos. For production use – use server-side token proxy. |
| 9 | + |
| 10 | +const OWNER = 'commitra'; |
| 11 | +const REPO = 'vanilla-verse'; |
| 12 | + |
| 13 | +const GITHUB_TOKEN = ''; // <-- optional: paste token here (or store in localStorage 'GH_TOKEN') |
| 14 | + |
| 15 | +const API_URL = `https://api.github.com/repos/${OWNER}/${REPO}/contributors?per_page=100`; |
| 16 | + |
| 17 | +/** Utility: small GitHub icon (SVG) */ |
| 18 | +function githubIconSVG() { |
| 19 | + return `<svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"> |
| 20 | + <path d="M12 .5C5.73.5.98 5.24.98 11.5c0 4.64 3.01 8.58 7.19 9.97.53.1.72-.23.72-.51 0-.25-.01-.91-.01-1.79-2.92.64-3.54-1.4-3.54-1.4-.48-1.22-1.17-1.55-1.17-1.55-.96-.66.07-.65.07-.65 1.06.08 1.62 1.09 1.62 1.09.94 1.6 2.48 1.14 3.08.87.09-.68.37-1.14.67-1.4-2.33-.27-4.78-1.16-4.78-5.17 0-1.14.41-2.07 1.08-2.8-.11-.27-.47-1.36.1-2.83 0 0 .88-.28 2.88 1.07A9.9 9.9 0 0112 6.8c.89.004 1.78.12 2.62.35 2-.35 2.88-1.07 2.88-1.07.57 1.47.21 2.56.1 2.83.67.73 1.08 1.66 1.08 2.8 0 4.02-2.46 4.89-4.8 5.15.38.33.72.98.72 1.98 0 1.43-.01 2.58-.01 2.94 0 .28.19.61.73.51 4.18-1.39 7.19-5.33 7.19-9.97C23.02 5.24 18.27.5 12 .5z" fill="currentColor"/> |
| 21 | + </svg>`; |
| 22 | +} |
| 23 | + |
| 24 | +/** Simple loading skeleton card */ |
| 25 | +function skeletonCard() { |
| 26 | + const el = document.createElement('div'); |
| 27 | + el.className = 'card'; |
| 28 | + el.innerHTML = ` |
| 29 | + <div class="avatar" aria-hidden="true"><div style="width:100%;height:100%;background:linear-gradient(90deg,#0b2134,#0b2842)"></div></div> |
| 30 | + <div class="info" style="min-width:0"> |
| 31 | + <div style="width:80%;height:14px;background:rgba(255,255,255,0.04);border-radius:8px"></div> |
| 32 | + <div style="height:10px;margin-top:10px;width:40%;background:rgba(255,255,255,0.02);border-radius:6px"></div> |
| 33 | + </div>`; |
| 34 | + return el; |
| 35 | +} |
| 36 | + |
| 37 | +/** Render contributors array */ |
| 38 | +function renderContributors(list) { |
| 39 | + const grid = document.getElementById('grid'); |
| 40 | + grid.innerHTML = ''; |
| 41 | + if (!Array.isArray(list) || list.length === 0) { |
| 42 | + grid.innerHTML = `<div class="empty">No contributors found.</div>`; |
| 43 | + document.getElementById('count').textContent = '0 contributors'; |
| 44 | + return; |
| 45 | + } |
| 46 | + |
| 47 | + document.getElementById('count').textContent = `${list.length} contributor${list.length>1 ? 's':''}`; |
| 48 | + |
| 49 | + list.forEach(user => { |
| 50 | + const card = document.createElement('article'); |
| 51 | + card.className = 'card'; |
| 52 | + const username = user.login || 'unknown'; |
| 53 | + const profileUrl = user.html_url || `https://github.com/${username}`; |
| 54 | + const avatar = user.avatar_url || ''; |
| 55 | + const contributions = user.contributions ?? 0; |
| 56 | + |
| 57 | + card.innerHTML = ` |
| 58 | + <div class="avatar" title="${username}"> |
| 59 | + <img loading="lazy" alt="${username}'s avatar" src="${avatar}" /> |
| 60 | + </div> |
| 61 | + <div class="info"> |
| 62 | + <div class="name"> |
| 63 | + <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="link" style="color:inherit;text-decoration:none;"> |
| 64 | + <span>${username}</span> |
| 65 | + </a> |
| 66 | + <span class="badge" aria-hidden="true">${githubIconSVG()}<span style="margin-left:6px">${contributions}</span></span> |
| 67 | + </div> |
| 68 | + <div class="username">@${username}</div> |
| 69 | + </div> |
| 70 | + `; |
| 71 | + grid.appendChild(card); |
| 72 | + }); |
| 73 | +} |
| 74 | + |
| 75 | +/** Fetch contributors from GitHub API */ |
| 76 | +async function fetchContributors() { |
| 77 | + const grid = document.getElementById('grid'); |
| 78 | + grid.innerHTML = ''; |
| 79 | + // show some skeletons |
| 80 | + for (let i=0;i<6;i++) grid.appendChild(skeletonCard()); |
| 81 | + |
| 82 | + // token fallback: localStorage > in-file constant |
| 83 | + const maybeToken = localStorage.getItem('GH_TOKEN') || GITHUB_TOKEN || ''; |
| 84 | + const headers = { 'Accept': 'application/vnd.github.v3+json' }; |
| 85 | + if (maybeToken) headers['Authorization'] = `token ${maybeToken}`; |
| 86 | + |
| 87 | + try { |
| 88 | + const resp = await fetch(API_URL, { headers }); |
| 89 | + if (resp.status === 403) { |
| 90 | + // Rate limited or forbidden |
| 91 | + const reset = resp.headers.get('x-ratelimit-reset'); |
| 92 | + let msg = 'Rate limit exceeded or access forbidden.'; |
| 93 | + if (reset) { |
| 94 | + const when = new Date(parseInt(reset,10)*1000); |
| 95 | + msg += ` Rate limit resets at ${when.toLocaleString()}.`; |
| 96 | + } |
| 97 | + grid.innerHTML = `<div class="empty">${msg} <br/>Tip: add a token to avoid limits.</div>`; |
| 98 | + document.getElementById('count').textContent = '0 contributors'; |
| 99 | + return []; |
| 100 | + } |
| 101 | + if (!resp.ok) { |
| 102 | + const text = await resp.text(); |
| 103 | + grid.innerHTML = `<div class="empty">Failed to load contributors. (${resp.status})</div>`; |
| 104 | + console.error('GitHub API error', resp.status, text); |
| 105 | + document.getElementById('count').textContent = '0 contributors'; |
| 106 | + return []; |
| 107 | + } |
| 108 | + const data = await resp.json(); |
| 109 | + // data is an array of contributors; map to consistent fields |
| 110 | + const mapped = data.map(u => ({ |
| 111 | + login: u.login, |
| 112 | + html_url: u.html_url, |
| 113 | + avatar_url: u.avatar_url, |
| 114 | + contributions: u.contributions |
| 115 | + })); |
| 116 | + renderContributors(mapped); |
| 117 | + return mapped; |
| 118 | + } catch (err) { |
| 119 | + console.error(err); |
| 120 | + grid.innerHTML = `<div class="empty">Network error while fetching contributors.</div>`; |
| 121 | + document.getElementById('count').textContent = '0 contributors'; |
| 122 | + return []; |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +/** Helpers: search & sort */ |
| 127 | +function applySearchSort(data) { |
| 128 | + const q = document.getElementById('search').value.trim().toLowerCase(); |
| 129 | + const sort = document.getElementById('sort').value; |
| 130 | + |
| 131 | + let filtered = data.filter(u => { |
| 132 | + if (!q) return true; |
| 133 | + return (u.login && u.login.toLowerCase().includes(q)); |
| 134 | + }); |
| 135 | + |
| 136 | + if (sort === 'contributions-desc') filtered.sort((a,b)=> (b.contributions||0) - (a.contributions||0)); |
| 137 | + if (sort === 'contributions-asc') filtered.sort((a,b)=> (a.contributions||0) - (b.contributions||0)); |
| 138 | + if (sort === 'name-asc') filtered.sort((a,b)=> (a.login||'').localeCompare(b.login||'')); |
| 139 | + if (sort === 'name-desc') filtered.sort((a,b)=> (b.login||'').localeCompare(a.login||'')); |
| 140 | + renderContributors(filtered); |
| 141 | +} |
| 142 | + |
| 143 | +/** Bind UI */ |
| 144 | +async function init() { |
| 145 | + const data = await fetchContributors(); |
| 146 | + // attach listeners |
| 147 | + document.getElementById('search').addEventListener('input', () => applySearchSort(data)); |
| 148 | + document.getElementById('sort').addEventListener('change', () => applySearchSort(data)); |
| 149 | + |
| 150 | + // keyboard shortcut: press "t" to toggle token prompt (for dev use) |
| 151 | + document.addEventListener('keydown', (e)=>{ |
| 152 | + if (e.key === 'T' || e.key === 't') { |
| 153 | + const current = localStorage.getItem('GH_TOKEN') || ''; |
| 154 | + const newToken = prompt('Enter GitHub token (will be stored in localStorage for this page). Leave blank to clear.', current); |
| 155 | + if (newToken === null) return; |
| 156 | + if (newToken.trim()) { |
| 157 | + localStorage.setItem('GH_TOKEN', newToken.trim()); |
| 158 | + alert('Token saved to localStorage. Refreshing...'); |
| 159 | + } else { |
| 160 | + localStorage.removeItem('GH_TOKEN'); |
| 161 | + alert('Token cleared. Refreshing...'); |
| 162 | + } |
| 163 | + window.location.reload(); |
| 164 | + } |
| 165 | + }); |
| 166 | +} |
| 167 | + |
| 168 | +document.addEventListener('DOMContentLoaded', init); |
0 commit comments