Skip to content

Commit 5143cc9

Browse files
committed
Add GitHub widget with live stats and dropdown
1 parent 1c7cd4d commit 5143cc9

1 file changed

Lines changed: 299 additions & 0 deletions

File tree

great_docs/assets/github-widget.js

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/**
2+
* GitHub Widget for Great-Docs
3+
*
4+
* A souped-up GitHub icon link with:
5+
* - Live star and fork counts
6+
* - Dropdown menu linking to repo, issues, PRs
7+
* - Caching to avoid API rate limits
8+
*/
9+
10+
(function() {
11+
'use strict';
12+
13+
// Configuration - will be set by data attributes
14+
let config = {
15+
owner: null,
16+
repo: null,
17+
cacheKey: 'great-docs-github-stats',
18+
cacheDuration: 5 * 60 * 1000 // 5 minutes
19+
};
20+
21+
/**
22+
* Initialize the GitHub widget
23+
*/
24+
function initGitHubWidget() {
25+
const widget = document.getElementById('github-widget');
26+
if (!widget) return;
27+
28+
config.owner = widget.dataset.owner;
29+
config.repo = widget.dataset.repo;
30+
31+
if (!config.owner || !config.repo) {
32+
console.warn('GitHub widget: Missing owner or repo data attributes');
33+
return;
34+
}
35+
36+
// Create the widget structure
37+
createWidgetStructure(widget);
38+
39+
// Fetch and display stats
40+
fetchGitHubStats();
41+
42+
// Setup dropdown behavior
43+
setupDropdown(widget);
44+
}
45+
46+
/**
47+
* Create the HTML structure for the widget
48+
*/
49+
function createWidgetStructure(container) {
50+
const repoUrl = `https://github.com/${config.owner}/${config.repo}`;
51+
52+
container.innerHTML = `
53+
<div class="gh-widget-trigger" role="button" aria-haspopup="true" aria-expanded="false" tabindex="0">
54+
<svg class="gh-icon" viewBox="0 0 16 16" width="20" height="20" fill="currentColor" aria-hidden="true">
55+
<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 0016 8c0-4.42-3.58-8-8-8z"/>
56+
</svg>
57+
<span class="gh-stats">
58+
<span class="gh-stat gh-stars" title="Stars">
59+
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
60+
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
61+
</svg>
62+
<span class="gh-count" data-stat="stars">-</span>
63+
</span>
64+
<span class="gh-stat gh-forks" title="Forks">
65+
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
66+
<path d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
67+
</svg>
68+
<span class="gh-count" data-stat="forks">-</span>
69+
</span>
70+
</span>
71+
<svg class="gh-dropdown-arrow" viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true">
72+
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
73+
</svg>
74+
</div>
75+
<div class="gh-dropdown" role="menu" aria-hidden="true">
76+
<a href="${repoUrl}" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener">
77+
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
78+
<path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
79+
</svg>
80+
Repository
81+
</a>
82+
<a href="${repoUrl}/issues" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener">
83+
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
84+
<path d="M8 9.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"/>
85+
<path fill-rule="evenodd" d="M8 0a8 8 0 100 16A8 8 0 008 0zM1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"/>
86+
</svg>
87+
Issues
88+
<span class="gh-badge gh-issues-count" style="display: none;"></span>
89+
</a>
90+
<a href="${repoUrl}/pulls" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener">
91+
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
92+
<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/>
93+
</svg>
94+
Pull Requests
95+
<span class="gh-badge gh-prs-count" style="display: none;"></span>
96+
</a>
97+
<div class="gh-dropdown-divider"></div>
98+
<a href="${repoUrl}/stargazers" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener">
99+
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
100+
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
101+
</svg>
102+
Stargazers
103+
</a>
104+
<a href="${repoUrl}/network/members" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener">
105+
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
106+
<path d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
107+
</svg>
108+
Forks
109+
</a>
110+
</div>
111+
`;
112+
}
113+
114+
/**
115+
* Fetch GitHub stats from API with caching
116+
*/
117+
async function fetchGitHubStats() {
118+
const cacheKey = `${config.cacheKey}-${config.owner}-${config.repo}`;
119+
120+
// Check cache first
121+
const cached = getFromCache(cacheKey);
122+
if (cached) {
123+
updateStatsDisplay(cached);
124+
return;
125+
}
126+
127+
try {
128+
// Fetch repo stats
129+
const repoResponse = await fetch(
130+
`https://api.github.com/repos/${config.owner}/${config.repo}`,
131+
{ headers: { 'Accept': 'application/vnd.github.v3+json' } }
132+
);
133+
134+
if (!repoResponse.ok) {
135+
throw new Error(`GitHub API error: ${repoResponse.status}`);
136+
}
137+
138+
const repoData = await repoResponse.json();
139+
140+
const stats = {
141+
stars: repoData.stargazers_count,
142+
forks: repoData.forks_count,
143+
openIssues: repoData.open_issues_count, // This includes PRs
144+
timestamp: Date.now()
145+
};
146+
147+
// Try to get separate issue and PR counts (optional, may hit rate limits)
148+
try {
149+
const [issuesResponse, prsResponse] = await Promise.all([
150+
fetch(`https://api.github.com/search/issues?q=repo:${config.owner}/${config.repo}+type:issue+state:open`, {
151+
headers: { 'Accept': 'application/vnd.github.v3+json' }
152+
}),
153+
fetch(`https://api.github.com/search/issues?q=repo:${config.owner}/${config.repo}+type:pr+state:open`, {
154+
headers: { 'Accept': 'application/vnd.github.v3+json' }
155+
})
156+
]);
157+
158+
if (issuesResponse.ok && prsResponse.ok) {
159+
const issuesData = await issuesResponse.json();
160+
const prsData = await prsResponse.json();
161+
stats.issues = issuesData.total_count;
162+
stats.prs = prsData.total_count;
163+
}
164+
} catch (e) {
165+
// Silently fail - we'll just not show separate counts
166+
}
167+
168+
// Cache the results
169+
saveToCache(cacheKey, stats);
170+
171+
// Update display
172+
updateStatsDisplay(stats);
173+
174+
} catch (error) {
175+
console.warn('GitHub widget: Failed to fetch stats', error);
176+
// Show fallback
177+
updateStatsDisplay({ stars: '?', forks: '?' });
178+
}
179+
}
180+
181+
/**
182+
* Update the display with fetched stats
183+
*/
184+
function updateStatsDisplay(stats) {
185+
const starsEl = document.querySelector('.gh-count[data-stat="stars"]');
186+
const forksEl = document.querySelector('.gh-count[data-stat="forks"]');
187+
const issuesBadge = document.querySelector('.gh-issues-count');
188+
const prsBadge = document.querySelector('.gh-prs-count');
189+
190+
if (starsEl) starsEl.textContent = formatCount(stats.stars);
191+
if (forksEl) forksEl.textContent = formatCount(stats.forks);
192+
193+
if (issuesBadge && stats.issues !== undefined) {
194+
issuesBadge.textContent = formatCount(stats.issues);
195+
issuesBadge.style.display = 'inline-flex';
196+
}
197+
198+
if (prsBadge && stats.prs !== undefined) {
199+
prsBadge.textContent = formatCount(stats.prs);
200+
prsBadge.style.display = 'inline-flex';
201+
}
202+
}
203+
204+
/**
205+
* Format large numbers (e.g., 1500 -> 1.5k)
206+
*/
207+
function formatCount(num) {
208+
if (num === undefined || num === null || num === '?') return '?';
209+
if (num >= 1000000) {
210+
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
211+
}
212+
if (num >= 1000) {
213+
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
214+
}
215+
return num.toString();
216+
}
217+
218+
/**
219+
* Get cached data if not expired
220+
*/
221+
function getFromCache(key) {
222+
try {
223+
const data = localStorage.getItem(key);
224+
if (!data) return null;
225+
226+
const parsed = JSON.parse(data);
227+
if (Date.now() - parsed.timestamp > config.cacheDuration) {
228+
localStorage.removeItem(key);
229+
return null;
230+
}
231+
return parsed;
232+
} catch (e) {
233+
return null;
234+
}
235+
}
236+
237+
/**
238+
* Save data to cache
239+
*/
240+
function saveToCache(key, data) {
241+
try {
242+
localStorage.setItem(key, JSON.stringify(data));
243+
} catch (e) {
244+
// Silently fail if localStorage is not available
245+
}
246+
}
247+
248+
/**
249+
* Setup dropdown toggle behavior
250+
*/
251+
function setupDropdown(widget) {
252+
const trigger = widget.querySelector('.gh-widget-trigger');
253+
const dropdown = widget.querySelector('.gh-dropdown');
254+
255+
if (!trigger || !dropdown) return;
256+
257+
// Toggle on click
258+
trigger.addEventListener('click', (e) => {
259+
e.stopPropagation();
260+
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
261+
trigger.setAttribute('aria-expanded', !isExpanded);
262+
dropdown.setAttribute('aria-hidden', isExpanded);
263+
widget.classList.toggle('gh-widget-open', !isExpanded);
264+
});
265+
266+
// Toggle on Enter/Space key
267+
trigger.addEventListener('keydown', (e) => {
268+
if (e.key === 'Enter' || e.key === ' ') {
269+
e.preventDefault();
270+
trigger.click();
271+
}
272+
});
273+
274+
// Close on click outside
275+
document.addEventListener('click', (e) => {
276+
if (!widget.contains(e.target)) {
277+
trigger.setAttribute('aria-expanded', 'false');
278+
dropdown.setAttribute('aria-hidden', 'true');
279+
widget.classList.remove('gh-widget-open');
280+
}
281+
});
282+
283+
// Close on Escape key
284+
document.addEventListener('keydown', (e) => {
285+
if (e.key === 'Escape') {
286+
trigger.setAttribute('aria-expanded', 'false');
287+
dropdown.setAttribute('aria-hidden', 'true');
288+
widget.classList.remove('gh-widget-open');
289+
}
290+
});
291+
}
292+
293+
// Initialize when DOM is ready
294+
if (document.readyState === 'loading') {
295+
document.addEventListener('DOMContentLoaded', initGitHubWidget);
296+
} else {
297+
initGitHubWidget();
298+
}
299+
})();

0 commit comments

Comments
 (0)