feat: add GitHub star nudges across the site

- Prepend attribution comment to "Copy all" exporter clipboard
- Show inline  Star nudge on individual rule copy (3s, dismisses automatically)
- Change StatsBar stars label to "engineers starred" for social proof
- Add milestone progress bar toward 10k stars in StatsBar
- Fix header/StatsBar showing "0" when SSR GitHub API fetch fails (use "—" placeholder)
This commit is contained in:
Samuel Berthe 2026-04-14 21:52:27 +02:00
parent d38511d7cb
commit bb055773b4
No known key found for this signature in database
GPG key ID: 64863511FFBD0E3C
5 changed files with 112 additions and 29 deletions

View file

@ -1,31 +1,53 @@
--- ---
import { GITHUB_URL } from '../data/site';
interface Props { interface Props {
targetId: string; targetId: string;
label?: string; label?: string;
variant?: 'icon' | 'text'; variant?: 'icon' | 'text';
class?: string; class?: string;
withAttribution?: boolean;
nudge?: boolean;
} }
const { targetId, label = 'Copy', variant = 'icon', class: extraClass = '' } = Astro.props; const { targetId, label = 'Copy', variant = 'icon', class: extraClass = '', withAttribution = false, nudge = false } = Astro.props;
const btnId = `copy-btn-${targetId}`; const btnId = `copy-btn-${targetId}`;
const nudgeId = `star-nudge-${btnId}`;
--- ---
{variant === 'icon' ? ( {variant === 'icon' ? (
<button <span class={`inline-flex items-center gap-1 ${extraClass}`}>
id={btnId} <button
data-copy-target={targetId} id={btnId}
aria-label="Copy to clipboard" data-copy-target={targetId}
class={`copy-btn inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded text-slate-400 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors ${extraClass}`} aria-label="Copy to clipboard"
> class="copy-btn inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded text-slate-400 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
<svg class="copy-icon w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> <svg class="copy-icon w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
<svg class="check-icon w-3.5 h-3.5 hidden text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </svg>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <svg class="check-icon w-3.5 h-3.5 hidden text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<span class="copy-label sr-only">Copy</span> </svg>
<span class="copied-label hidden text-green-500 not-sr-only text-xs">Copied!</span> <span class="copy-label sr-only">Copy</span>
</button> <span class="copied-label hidden text-green-500 not-sr-only text-xs">Copied!</span>
</button>
{nudge && (
<a
id={nudgeId}
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
class="hidden items-center gap-0.5 text-xs text-yellow-500 hover:text-yellow-600 dark:text-yellow-400 dark:hover:text-yellow-300 transition-colors whitespace-nowrap"
aria-label="Star on GitHub"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
Star
</a>
)}
</span>
) : ( ) : (
<button <button
id={btnId} id={btnId}
@ -43,7 +65,7 @@ const btnId = `copy-btn-${targetId}`;
</button> </button>
)} )}
<script define:vars={{ btnId }}> <script define:vars={{ btnId, nudgeId, withAttribution, nudge, GITHUB_URL }}>
const btn = document.getElementById(btnId); const btn = document.getElementById(btnId);
if (!(btn instanceof HTMLButtonElement)) return; if (!(btn instanceof HTMLButtonElement)) return;
if (btn.dataset.copyBound === 'true') return; if (btn.dataset.copyBound === 'true') return;
@ -55,13 +77,17 @@ const btnId = `copy-btn-${targetId}`;
const target = document.getElementById(targetId); const target = document.getElementById(targetId);
if (!target) return; if (!target) return;
const text = target.textContent ?? ''; let text = target.textContent ?? '';
text = withAttribution
? `# Source: ${GITHUB_URL}\n${text.trim()}`
: text.trim();
try { try {
await navigator.clipboard.writeText(text.trim()); await navigator.clipboard.writeText(text);
} catch { } catch {
// Fallback for older browsers // Fallback for older browsers
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = text.trim(); ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;'; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;';
document.body.appendChild(ta); document.body.appendChild(ta);
ta.select(); ta.select();
@ -87,6 +113,19 @@ const btnId = `copy-btn-${targetId}`;
copiedLabel?.classList.add('hidden'); copiedLabel?.classList.add('hidden');
}, 2000); }, 2000);
// Inline star nudge
if (nudge) {
const nudgeEl = document.getElementById(nudgeId);
if (nudgeEl) {
nudgeEl.classList.remove('hidden');
nudgeEl.classList.add('inline-flex');
setTimeout(() => {
nudgeEl.classList.add('hidden');
nudgeEl.classList.remove('inline-flex');
}, 3000);
}
}
window.dispatchEvent(new CustomEvent('copy-success')); window.dispatchEvent(new CustomEvent('copy-success'));
}); });
</script> </script>

View file

@ -68,7 +68,7 @@ const exporterPrefix = showExporterNumber
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<CopyButton targetId={allRulesId} label="Copy all" variant="text" /> <CopyButton targetId={allRulesId} label="Copy all" variant="text" withAttribution={true} />
</div> </div>
</div> </div>

View file

@ -25,7 +25,7 @@ try {
} }
} catch {} } catch {}
const starsLabel = stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : String(stars); const starsLabel = stars > 0 ? (stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : String(stars)) : '—';
--- ---
<!-- Main header --> <!-- Main header -->

View file

@ -37,7 +37,7 @@ const codeId = `code-${anchorId}`;
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg> </svg>
</a> </a>
<CopyButton targetId={codeId} variant="icon" /> <CopyButton targetId={codeId} variant="icon" nudge={true} />
</div> </div>
</div> </div>

View file

@ -1,11 +1,14 @@
--- ---
import { getTotalRuleCount, getTotalExporterCount, data } from '../data/rules'; import { getTotalRuleCount, getTotalExporterCount, data } from '../data/rules';
import { GITHUB_API_REPO_URL } from '../data/site'; import { GITHUB_API_REPO_URL, GITHUB_URL } from '../data/site';
const totalRules = getTotalRuleCount(); const totalRules = getTotalRuleCount();
const totalExporters = getTotalExporterCount(); const totalExporters = getTotalExporterCount();
const totalGroups = data.groups.length; const totalGroups = data.groups.length;
const STAR_MILESTONE = 10000;
const MILESTONE_LABEL = '10k';
let stars = 0; let stars = 0;
try { try {
const res = await fetch(GITHUB_API_REPO_URL, { const res = await fetch(GITHUB_API_REPO_URL, {
@ -17,7 +20,10 @@ try {
} }
} catch {} } catch {}
const starsLabel = stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : String(stars); const starsLabel = stars > 0 ? (stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : String(stars)) : '—';
const progressPct = stars > 0 ? Math.min(100, (stars / STAR_MILESTONE) * 100).toFixed(1) : '0';
const starsFormatted = stars > 0 ? stars.toLocaleString('en') : '';
const milestoneFormatted = STAR_MILESTONE.toLocaleString('en');
--- ---
<div class="flex flex-wrap justify-center gap-6 sm:gap-10 py-4 text-center"> <div class="flex flex-wrap justify-center gap-6 sm:gap-10 py-4 text-center">
@ -33,33 +39,70 @@ const starsLabel = stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : String(star
<div class="text-2xl font-bold text-brand dark:text-brand-dark">{totalGroups}</div> <div class="text-2xl font-bold text-brand dark:text-brand-dark">{totalGroups}</div>
<div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">categories</div> <div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">categories</div>
</div> </div>
<a href="https://github.com/samber/awesome-prometheus-alerts" target="_blank" rel="noopener noreferrer" class="group"> <a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" class="group">
<div class="text-2xl font-bold text-brand dark:text-brand-dark flex items-center justify-center gap-1"> <div class="text-2xl font-bold text-brand dark:text-brand-dark flex items-center justify-center gap-1">
<svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg> </svg>
<span id="statsbar-stars">{starsLabel}</span> <span id="statsbar-stars">{starsLabel}</span>
</div> </div>
<div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 group-hover:text-brand dark:group-hover:text-brand-dark transition-colors">GitHub stars</div> <div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 group-hover:text-brand dark:group-hover:text-brand-dark transition-colors">engineers starred</div>
</a> </a>
</div> </div>
<script> <!-- Milestone progress bar -->
<div id="star-milestone-row" class={`mt-2 px-4 ${stars === 0 ? 'opacity-0' : ''}`}>
<div class="max-w-xs mx-auto">
<div class="flex items-center justify-between text-xs text-slate-400 dark:text-slate-500 mb-1">
<span>
<span id="statsbar-stars-count">{starsFormatted}</span><span id="statsbar-milestone-suffix">{stars > 0 ? ` / ${milestoneFormatted} — help us reach ${MILESTONE_LABEL}!` : ''}</span>
</span>
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" class="text-yellow-500 hover:text-yellow-600 dark:text-yellow-400 dark:hover:text-yellow-300 font-medium transition-colors flex items-center gap-0.5 ml-2 flex-shrink-0">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
Star it
</a>
</div>
<div class="bg-slate-100 dark:bg-slate-800 rounded-full h-1.5">
<div
id="statsbar-star-bar"
class="bg-yellow-400 dark:bg-yellow-500 h-1.5 rounded-full transition-all duration-700"
style={`width: ${progressPct}%`}
></div>
</div>
</div>
</div>
<script define:vars={{ STAR_MILESTONE, MILESTONE_LABEL }}>
const starsEl = document.getElementById('statsbar-stars'); const starsEl = document.getElementById('statsbar-stars');
const starsCountEl = document.getElementById('statsbar-stars-count');
const milestoneSuffixEl = document.getElementById('statsbar-milestone-suffix');
const barEl = document.getElementById('statsbar-star-bar');
const milestoneRow = document.getElementById('star-milestone-row');
if (starsEl) { if (starsEl) {
const CACHE_KEY = 'gh_stars_apa'; const CACHE_KEY = 'gh_stars_apa';
const CACHE_TTL = 3600 * 1000; const CACHE_TTL = 3600 * 1000;
function fmt(n: number): string { function fmt(n) {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
} }
function updateMilestone(n) {
if (starsCountEl) starsCountEl.textContent = n.toLocaleString('en');
if (milestoneSuffixEl) milestoneSuffixEl.textContent = ` / ${STAR_MILESTONE.toLocaleString('en')} — help us reach ${MILESTONE_LABEL}!`;
if (barEl) barEl.style.width = `${Math.min(100, (n / STAR_MILESTONE) * 100).toFixed(1)}%`;
if (milestoneRow) milestoneRow.classList.remove('opacity-0');
}
const cached = sessionStorage.getItem(CACHE_KEY); const cached = sessionStorage.getItem(CACHE_KEY);
let isFresh = false; let isFresh = false;
if (cached) { if (cached) {
const { value, ts } = JSON.parse(cached); const { value, ts } = JSON.parse(cached);
if (Date.now() - ts < CACHE_TTL) { if (Date.now() - ts < CACHE_TTL) {
starsEl.textContent = fmt(value); starsEl.textContent = fmt(value);
updateMilestone(value);
isFresh = true; isFresh = true;
} }
} }
@ -71,6 +114,7 @@ const starsLabel = stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : String(star
.then((data) => { .then((data) => {
if (data?.stargazers_count) { if (data?.stargazers_count) {
starsEl.textContent = fmt(data.stargazers_count); starsEl.textContent = fmt(data.stargazers_count);
updateMilestone(data.stargazers_count);
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ value: data.stargazers_count, ts: Date.now() })); sessionStorage.setItem(CACHE_KEY, JSON.stringify({ value: data.stargazers_count, ts: Date.now() }));
} }
}) })