Files
greasysprocket-website/astro/src/pages/[slug].astro
T
Jose Juan Moñino 903e51cf5b refactor: unify /coffee → /support
- Rename coffee.astro → support.astro (broader scope: ko-fi, crypto, wallets)
- Update all /coffee links → /support across website + extension pages
- Update nav, footer, hero, and all 5 extension .md files
- TabFM coffee banner already points to /support
2026-07-10 19:13:20 +02:00

270 lines
9.3 KiB
Plaintext

---
import Base from '../layouts/Base.astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const extensions = await getCollection('extensions');
return extensions.map((ext) => ({
params: { slug: ext.slug },
props: { ext },
}));
}
const { ext } = Astro.props;
const { Content, headings } = await ext.render();
const tocItems = headings
.filter((h: any) => h.depth === 2)
.map((h: any) => ({ text: h.text, slug: h.slug }));
const siteUrl = 'https://greasysprocket.com';
const canonicalUrl = `${siteUrl}/${ext.slug}`;
const ogImage = ext.data.screenshot
? new URL(ext.data.screenshot, siteUrl).href
: undefined;
const categoryLabels: Record<string, string> = {
'productivity': 'Productivity',
'developer-tools': 'Developer Tools',
'fun': 'Fun',
'privacy': 'Privacy',
'utilities': 'Utilities',
};
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: ext.data.name,
description: ext.data.description,
applicationCategory: 'BrowserExtension',
operatingSystem: 'Chrome',
url: canonicalUrl,
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
...(ext.data.chromeStoreUrl && { installUrl: ext.data.chromeStoreUrl }),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Extensions', item: siteUrl },
{ '@type': 'ListItem', position: 2, name: ext.data.name, item: canonicalUrl },
],
},
...(ext.data.faq ? [{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: ext.data.faq.map((f: any) => ({
'@type': 'Question',
name: f.question,
acceptedAnswer: { '@type': 'Answer', text: f.answer },
})),
}] : []),
];
---
<Base
title={`${ext.data.name} — Free Chrome Extension | Greasy Sprocket`}
description={ext.data.description}
canonical={canonicalUrl}
ogImage={ogImage}
jsonLd={jsonLd}
>
<section class="ext-hero">
<div class="container">
<div class="info">
<div class="ext-hero-meta">
<span class="ext-tag ext-tag-category" data-category={ext.data.category}>
{categoryLabels[ext.data.category] || ext.data.category}
</span>
<div class="ext-rating-inline" id="rating-display">
<span class="ext-stars" id="rating-stars">
<span class="star star-empty">★</span>
<span class="star star-empty">★</span>
<span class="star star-empty">★</span>
<span class="star star-empty">★</span>
<span class="star star-empty">★</span>
</span>
<span class="ext-rating-count" id="rating-summary">No ratings yet</span>
</div>
</div>
<h1>{ext.data.name}</h1>
<p class="tagline">{ext.data.tagline}</p>
{ext.data.tags.length > 0 && (
<div class="ext-tags">
{ext.data.tags.map((tag: string) => (
<span class="tag-pill">{tag}</span>
))}
</div>
)}
<div class="actions">
{ext.data.chromeStoreUrl && (
<a href={ext.data.chromeStoreUrl} class="btn btn-primary" target="_blank" rel="noopener">
Add to Chrome
</a>
)}
{ext.data.downloadUrl && (
<a href={ext.data.downloadUrl} class="btn btn-outline">
Download ZIP
</a>
)}
</div>
</div>
{ext.data.screenshot && (
<div class="screenshot">
<img src={ext.data.screenshot} alt={`${ext.data.name} screenshot`} />
</div>
)}
</div>
</section>
<section class="section">
<div class="container ext-layout">
{tocItems.length > 0 && (
<aside class="toc">
<div class="section-label">Contents</div>
<nav>
<ol>
{tocItems.map((item: any) => (
<li><a href={`#${item.slug}`}>{item.text}</a></li>
))}
<li><a href="#rate-this">Rate this</a></li>
{ext.data.faq && ext.data.faq.length > 0 && (
<li><a href="#faq">FAQ</a></li>
)}
</ol>
</nav>
</aside>
)}
<div class="ext-content">
<div class="section-label">Features</div>
<div class="feature-list">
{ext.data.features?.map((feature: string) => (
<div class="feature-item">
<span class="check">✓</span>
<h4>{feature}</h4>
</div>
))}
</div>
<div class="prose">
<Content />
</div>
<!-- Vote widget — integrated at the end of content -->
<div class="rate-cta" id="rate-this">
<div class="rate-cta-inner">
<h3>How would you rate {ext.data.name}?</h3>
<p class="rate-cta-subtitle" id="rating-vote-msg">Click a star to vote — no account needed.</p>
<div class="rating-vote-stars" id="rating-vote-stars">
<button class="star-btn" data-value="1" aria-label="1 star">★</button>
<button class="star-btn" data-value="2" aria-label="2 stars">★</button>
<button class="star-btn" data-value="3" aria-label="3 stars">★</button>
<button class="star-btn" data-value="4" aria-label="4 stars">★</button>
<button class="star-btn" data-value="5" aria-label="5 stars">★</button>
</div>
</div>
</div>
</div>
</div>
</section>
{ext.data.faq && ext.data.faq.length > 0 && (
<section class="section" id="faq">
<div class="container faq-container">
<div class="section-label">FAQ</div>
<h2>Frequently asked questions</h2>
<div class="faq-list">
{ext.data.faq.map((f: any, i: number) => (
<details class="faq-item" open={i === 0}>
<summary>
<span>{f.question}</span>
<span class="faq-toggle" aria-hidden="true"></span>
</summary>
<div class="faq-answer">
<p>{f.answer}</p>
</div>
</details>
))}
</div>
</div>
</section>
)}
<script is:inline define:vars={{ extSlug: ext.slug }}>
(async () => {
const starsEl = document.getElementById('rating-stars');
const summaryEl = document.getElementById('rating-summary');
const voteStars = document.getElementById('rating-vote-stars');
const voteMsg = document.getElementById('rating-vote-msg');
function renderStars(el, avg) {
const full = Math.floor(avg);
const hasHalf = avg % 1 >= 0.25 && avg % 1 < 0.75;
const empty = 5 - full - (hasHalf ? 1 : 0);
let html = '';
for (let i = 0; i < full; i++) html += '<span class="star star-full">★</span>';
if (hasHalf) html += '<span class="star star-half">★</span>';
for (let i = 0; i < empty; i++) html += '<span class="star star-empty">★</span>';
el.innerHTML = html;
}
// Load current ratings
try {
const res = await fetch(`/ratings?ext=${encodeURIComponent(extSlug)}`);
if (res.ok) {
const data = await res.json();
if (data.count === 0) {
summaryEl.textContent = 'No ratings yet';
} else {
renderStars(starsEl, data.average);
summaryEl.textContent = `${data.average.toFixed(1)} (${data.count})`;
}
}
} catch (e) {}
// Vote handling
const buttons = voteStars.querySelectorAll('.star-btn');
buttons.forEach((btn) => {
btn.addEventListener('mouseenter', () => {
const val = parseInt(btn.dataset.value);
buttons.forEach((b, i) => b.classList.toggle('hover', i < val));
});
btn.addEventListener('mouseleave', () => {
buttons.forEach((b) => b.classList.remove('hover'));
});
btn.addEventListener('click', async () => {
const val = parseInt(btn.dataset.value);
buttons.forEach((b, i) => b.classList.toggle('selected', i < val));
voteMsg.textContent = 'Submitting...';
voteMsg.className = 'rate-cta-subtitle';
try {
const res = await fetch('/rate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ext: extSlug, rating: val }),
});
if (res.ok) {
voteMsg.textContent = 'Thanks for your vote!';
voteMsg.className = 'rate-cta-subtitle success';
const r2 = await fetch(`/ratings?ext=${encodeURIComponent(extSlug)}`);
if (r2.ok) {
const d2 = await r2.json();
if (d2.count > 0) {
renderStars(starsEl, d2.average);
summaryEl.textContent = `${d2.average.toFixed(1)} (${d2.count})`;
}
}
} else {
voteMsg.textContent = 'Something went wrong. Try again.';
voteMsg.className = 'rate-cta-subtitle error';
}
} catch (e) {
voteMsg.textContent = 'Network error. Try again.';
voteMsg.className = 'rate-cta-subtitle error';
}
});
});
})();
</script>
</Base>