903e51cf5b
- 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
270 lines
9.3 KiB
Plaintext
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> |