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
This commit is contained in:
@@ -6,6 +6,14 @@ const extensions = defineCollection({
|
||||
name: z.string(),
|
||||
tagline: z.string(),
|
||||
description: z.string(),
|
||||
category: z.enum([
|
||||
'productivity',
|
||||
'developer-tools',
|
||||
'fun',
|
||||
'privacy',
|
||||
'utilities',
|
||||
]),
|
||||
tags: z.array(z.string()).default([]),
|
||||
features: z.array(z.string()).optional(),
|
||||
screenshot: z.string().optional(),
|
||||
chromeStoreUrl: z.string().url().optional().or(z.literal('')),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: FocusPet
|
||||
tagline: A virtual pet that thrives when you focus. Tamagotchi meets productivity.
|
||||
description: "FocusPet is a free Chrome extension that turns focus into a game. A pixel-art cat lives on your screen — it gains XP and evolves when you stay focused, and gets sad when you get distracted. Built-in pomodoro timer, 6 evolution stages, streak tracking, and a draggable overlay. 100% free, no premium tiers."
|
||||
category: productivity
|
||||
tags: ["gamification", "pomodoro", "focus", "pixel-art", "tamagotchi"]
|
||||
|
||||
features:
|
||||
- "Pixel-art cat that evolves through 6 stages: Egg → Baby → Child → Teen → Adult → Master"
|
||||
- "Automatic focus detection — stays on same tab 5+ min = XP gained"
|
||||
- "Distraction tracking — rapid tab switches (<30s) make your pet sad"
|
||||
- "Built-in pomodoro timer (25 min default) with +50 XP per completed session"
|
||||
- "Floating overlay mascot on any page — draggable, with idle/happy/sad animations"
|
||||
- "Health, streak, and daily focus stats tracked in the popup"
|
||||
- "Game Boy-style 4-color pixel art, synthesized in real-time on canvas"
|
||||
- "100% free, no premium tiers, no tracking, no analytics"
|
||||
screenshot: ""
|
||||
chromeStoreUrl: ""
|
||||
downloadUrl: "https://git.greasysprocket.com/monyi/focuspet/releases"
|
||||
faq:
|
||||
- question: "Is FocusPet really free?"
|
||||
answer: "Yes, 100% free. All 6 evolution stages, the pomodoro timer, and the floating mascot are available to everyone. No premium tiers, no locked features, no usage limits. If you enjoy it, you can buy me a coffee — but there's zero pressure."
|
||||
- question: "Does FocusPet collect any data?"
|
||||
answer: "No. FocusPet doesn't collect any data. No analytics, no tracking, no telemetry. Your pet's state (XP, stage, health, stats) is stored locally in your browser via chrome.storage.local. Nothing leaves your device."
|
||||
- question: "How does FocusPet detect focus vs distraction?"
|
||||
answer: "FocusPet tracks tab activity in the background. When you stay on the same tab for 5 minutes or more, your pet gains XP (2 XP per minute focused). When you switch tabs in under 30 seconds, it counts as a distraction — your pet loses 5 XP and its streak resets. You can also use the built-in pomodoro timer for a guaranteed +50 XP per completed 25-minute session."
|
||||
- question: "What are the evolution stages?"
|
||||
answer: "Your pet evolves through 6 stages based on total XP: Egg (0 XP), Baby (50 XP), Child (200 XP), Teen (500 XP), Adult (1000 XP), and Master (2500 XP). Each stage adds visual accessories — a hat at Child, a cape at Adult, and a golden crown at Master."
|
||||
- question: "What's the floating cat on my screen?"
|
||||
answer: "That's your FocusPet! A 64×64 pixel-art cat overlay renders on every page (bottom-right by default). It's draggable — click and hold to move it anywhere. The cat changes color and expression based on its mood: gold when happy, blue when sad, purple when sleeping. It also bounces when you click it."
|
||||
- question: "Does FocusPet work offline?"
|
||||
answer: "Yes. FocusPet works completely offline. The pixel-art sprite is drawn in real-time on an HTML canvas — no images, no external assets. The only permissions requested are storage (to save your pet's state) and idle detection (to make your pet sleep when you're away)."
|
||||
---
|
||||
|
||||
FocusPet turns focus into a game. A pixel-art cat lives on your screen, gains XP when you
|
||||
stay focused, and gets sad when you get distracted. It's a tamagotchi that grows through
|
||||
your productivity.
|
||||
|
||||
## How it works
|
||||
|
||||
FocusPet runs quietly in the background, tracking your tab activity:
|
||||
|
||||
- **Stay on a tab 5+ minutes** → your pet gains 2 XP per minute focused. Health regenerates.
|
||||
- **Switch tabs in under 30 seconds** → that's a distraction. Your pet loses 5 XP, its
|
||||
mood drops to sad, and its streak resets to zero.
|
||||
- **Complete a pomodoro session** → +50 XP, health +10, and your pet celebrates.
|
||||
- **Go idle or lock your screen** → your pet falls asleep (purple, closed eyes).
|
||||
- **New day** → daily counters reset (focus minutes, distraction count), but total XP and
|
||||
stage are permanent.
|
||||
|
||||
## Evolution stages
|
||||
|
||||
| Stage | XP Required | Visual |
|
||||
|-------|-------------|--------|
|
||||
| Egg | 0 | Basic cat body |
|
||||
| Baby | 50 | Basic cat body |
|
||||
| Child | 200 | + Red hat |
|
||||
| Teen | 500 | + Red hat |
|
||||
| Adult | 1000 | + Red hat + Purple cape |
|
||||
| Master | 2500 | + Red hat + Purple cape + Golden crown |
|
||||
|
||||
Once your pet reaches Master, it's maxed out — but you can keep grinding XP to flex your
|
||||
total.
|
||||
|
||||
## The pomodoro timer
|
||||
|
||||
The popup includes a 25-minute pomodoro timer. Hit **Start Focus** and a countdown begins.
|
||||
If you complete the full 25 minutes: +50 XP, health boost, streak increment, and a
|
||||
notification. If you interrupt it (or go idle): -10 XP, streak resets, and your pet gets sad.
|
||||
|
||||
## The floating mascot
|
||||
|
||||
A 64×64 pixel-art cat renders as a fixed overlay on every page you visit — bottom-right by
|
||||
default. It's:
|
||||
|
||||
- **Draggable** — click and hold to move it anywhere on the page
|
||||
- **Animated** — breathing idle, tail wag, bounce on click
|
||||
- **Mood-reactive** — gold body when happy, blue when sad, purple when sleeping
|
||||
- **Stage-aware** — shows hat, cape, and crown as your pet evolves
|
||||
- **Dismissible** — it's a canvas element, non-intrusive at 85% opacity
|
||||
|
||||
## Privacy
|
||||
|
||||
FocusPet doesn't collect any data. No analytics, no tracking, no telemetry.
|
||||
Your pet's state lives in `chrome.storage.local` — it never leaves your browser.
|
||||
The only permissions are `storage` (to persist your pet), `idle` (to detect when you're
|
||||
away), and `notifications` (to tell you when a pomodoro completes). The content script
|
||||
runs on all URLs but only draws a canvas — it doesn't read page content or make network
|
||||
requests.
|
||||
|
||||
## Free forever
|
||||
|
||||
FocusPet is 100% free. All 6 evolution stages, the pomodoro timer, and the floating
|
||||
mascot are available to everyone. There are no premium tiers, no locked features, no
|
||||
usage limits. If you enjoy it, you can [buy me a coffee](/support) — but there's zero
|
||||
pressure.
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: ScrollFlow
|
||||
tagline: Smooth scrolling, custom scrollbars, and scroll tools. 10 themes, zero config, works everywhere.
|
||||
description: "ScrollFlow is a free Chrome extension that enhances scrolling with smooth interpolation, auto-colored custom scrollbars, a progress bar, scroll-to-top button, and vim-style keyboard navigation. 10 built-in scrollbar themes, all free. No tracking, no premium tiers, 100% free."
|
||||
category: utilities
|
||||
tags: ["smooth-scroll", "scrollbar", "customization", "accessibility", "ux"]
|
||||
|
||||
features:
|
||||
- "Smooth scroll with lerp interpolation — configurable duration and easing"
|
||||
- "10 scrollbar themes (minimal, round, neon, glass, wood, carbon, gold, sakura, ocean, ember)"
|
||||
- "Auto-color scrollbar — detects the site's accent color and tints to match"
|
||||
- "Scroll progress bar at the top of every page"
|
||||
- "Scroll-to-top floating button after 400px"
|
||||
- "Vim-style keyboard scroll: j/k, d/u, Space, gg, G"
|
||||
- "Momentum scroll with physics-based inertia"
|
||||
- "Auto-hide scrollbar — visible only while scrolling"
|
||||
- "FPS auto-degrade — drops to native scroll if FPS drops below 30"
|
||||
- "100% free, no premium tiers, no ads, no tracking"
|
||||
screenshot: ""
|
||||
chromeStoreUrl: ""
|
||||
downloadUrl: "https://git.d0a1.es/d0a1/scrollflow/releases"
|
||||
faq:
|
||||
- question: "Is ScrollFlow really free?"
|
||||
answer: "Yes, 100% free. All 10 scrollbar themes and every feature are available to everyone. No premium tiers, no locked features, no usage limits. If you enjoy it, you can buy me a coffee — but there's zero pressure."
|
||||
- question: "Does ScrollFlow collect any data?"
|
||||
answer: "No. ScrollFlow doesn't collect any data. No analytics, no tracking, no telemetry. All preferences are stored locally in your browser via chrome.storage. Nothing leaves your device."
|
||||
- question: "Will ScrollFlow slow down my browser?"
|
||||
answer: "No. ScrollFlow uses requestAnimationFrame for smooth scrolling and automatically falls back to native scroll if your framerate drops below 30fps. The performance guard re-enables smooth scroll when your framerate recovers."
|
||||
- question: "Does ScrollFlow work on every website?"
|
||||
answer: "ScrollFlow works on most websites. Some sites with their own custom scroll handling (Google Docs, Figma) may not be affected. The extension detects sites with CSS scroll-behavior: smooth and doesn't interfere."
|
||||
- question: "How do I change the scrollbar theme or scroll settings?"
|
||||
answer: "Click the ScrollFlow icon in your Chrome toolbar to open the popup. From there you can toggle features on/off, pick a scrollbar theme, adjust scroll duration, and choose an easing curve."
|
||||
- question: "Can I use vim-style keyboard scrolling?"
|
||||
answer: "Yes. ScrollFlow supports vim-style keys: j/k for step scroll, d/u for half-page, Space for full-page, gg for top, G for bottom. This feature is enabled by default and can be toggled from the popup."
|
||||
---
|
||||
|
||||
ScrollFlow enhances scrolling on every website with smooth interpolation, custom scrollbars,
|
||||
and practical scroll tools — all in one extension.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Default | What it does |
|
||||
|---------|---------|---|
|
||||
| **Smooth scroll** | ON | Lerp-based smooth scrolling with configurable duration + easing |
|
||||
| **Auto-color scrollbar** | ON | Detects the site's accent color and tints the scrollbar to match |
|
||||
| **Scrollbar themes** | minimal | 10 built-in themes (minimal, round, neon, glass, wood, carbon, gold, sakura, ocean, ember) |
|
||||
| **Auto-hide scrollbar** | OFF | Scrollbar invisible until you scroll, fades in/out |
|
||||
| **Progress bar** | ON | Thin bar at top showing scroll progress, auto-colored to match site |
|
||||
| **Scroll-to-top** | ON | Floating button appears after scrolling 400px, smooth scroll back to top |
|
||||
| **Keyboard scroll** | ON | Vim-style: j/k (step), d/u (half page), Space (full page), gg (top), G (bottom) |
|
||||
| **Momentum scroll** | OFF | Physics-based inertia — scroll keeps going and decelerates naturally |
|
||||
| **FPS auto-degrade** | auto | Drops to native scroll if FPS < 30, re-enables when recovered |
|
||||
|
||||
## Scrollbar themes
|
||||
|
||||
10 themes, all free:
|
||||
|
||||
| Theme | Vibe |
|
||||
|-------|------|
|
||||
| Minimal | Thin, dark/light adaptive |
|
||||
| Round | Rounded thumb, accent-colored |
|
||||
| Neon | Glowing cyberpunk aesthetic |
|
||||
| Glass | Frosted translucent |
|
||||
| Wood | Warm, organic |
|
||||
| Carbon | Dark weave texture |
|
||||
| Gold | Luxurious metallic |
|
||||
| Sakura | Soft pink cherry blossom |
|
||||
| Ocean | Deep blue gradients |
|
||||
| Ember | Fiery warm tones |
|
||||
|
||||
## How it works
|
||||
|
||||
ScrollFlow injects a content script that intercepts wheel events and interpolates
|
||||
scroll position with lerp (linear interpolation) at 60fps via requestAnimationFrame.
|
||||
The easing curve is configurable (linear, ease-out, smooth, bounce).
|
||||
|
||||
The scrollbar customization works by injecting CSS `::-webkit-scrollbar` rules into
|
||||
the page. The auto-color feature detects the site's accent color by scanning link
|
||||
and button elements, then tints the scrollbar to match — subtle, not jarring.
|
||||
|
||||
A performance guard monitors framerate. If FPS drops below 30 (heavy pages, large
|
||||
documents), ScrollFlow automatically falls back to native scroll. When framerate
|
||||
recovers, smooth scroll re-engages.
|
||||
|
||||
## Privacy
|
||||
|
||||
ScrollFlow doesn't collect any data. No analytics, no tracking, no telemetry.
|
||||
All preferences are stored locally via `chrome.storage.sync`. The extension
|
||||
only requests `storage` permission — no host permissions, no tabs, no browsing
|
||||
history access.
|
||||
|
||||
## Free forever
|
||||
|
||||
ScrollFlow is 100% free. All 10 themes and every feature are available to everyone.
|
||||
There are no premium tiers, no locked features, no usage limits. If you enjoy it,
|
||||
you can [buy me a coffee](/support) — but there's zero pressure.
|
||||
@@ -2,6 +2,9 @@
|
||||
name: TabFM
|
||||
tagline: Lo-fi radio in every new tab. 15 ambient soundscapes, a clock, and a search bar — 100% offline.
|
||||
description: "TabFM is a free Chrome extension that turns every new tab into a lo-fi radio station. Choose from 15 ambient soundscapes — rainy loft, night drive, forest dawn, coffee shop, deep space, and more. Built-in clock and Google search. Everything offline, no streaming, no tracking. 100% free, no premium tiers."
|
||||
category: fun
|
||||
tags: ["audio", "lo-fi", "ambient", "new-tab", "offline"]
|
||||
|
||||
features:
|
||||
- "15 ambient soundscapes (rain, forest, ocean, deep space, vinyl, and more)"
|
||||
- "Clock + Google search built into the new tab page"
|
||||
@@ -63,4 +66,4 @@ selected soundscape).
|
||||
|
||||
TabFM is 100% free. All 15 soundscapes are available to everyone. There are no
|
||||
premium tiers, no locked features, no usage limits. If you enjoy it, you can
|
||||
[buy me a coffee](/coffee) — but there's zero pressure.
|
||||
[buy me a coffee](/support) — but there's zero pressure.
|
||||
@@ -2,6 +2,9 @@
|
||||
name: Text Tools
|
||||
tagline: Convert text instantly — UPPERCASE, lowercase, camelCase, kebab-case, and 9 more. Popup or right-click. Free.
|
||||
description: "Text Tools is a free Chrome extension that converts text instantly. 12 transforms including UPPERCASE, lowercase, Title Case, camelCase, kebab-case, snake_case, and more. Works from the toolbar popup or the right-click context menu. No tracking, no premium tiers, 100% free."
|
||||
category: developer-tools
|
||||
tags: ["text", "case-conversion", "clipboard", "formatter", "utility"]
|
||||
|
||||
features:
|
||||
- "12 transforms: UPPERCASE, lowercase, Title Case, Sentence case"
|
||||
- "Code cases: camelCase, PascalCase, kebab-case, snake_case, CONSTANT_CASE"
|
||||
@@ -59,4 +62,4 @@ Everything runs locally in your browser. Nothing is sent to any server.
|
||||
|
||||
Text Tools is 100% free. All 12 transforms are available to everyone. There are no
|
||||
premium tiers, no locked features, no usage limits. If you enjoy it, you can
|
||||
[buy me a coffee](/coffee) — but there's zero pressure.
|
||||
[buy me a coffee](/support) — but there's zero pressure.
|
||||
@@ -2,6 +2,9 @@
|
||||
name: ThockBoard
|
||||
tagline: Mechanical keyboard sounds for every keystroke. 16 sound packs, zero setup.
|
||||
description: "ThockBoard is a free Chrome extension that plays satisfying mechanical keyboard sounds on every keystroke, on any website. Choose from 16 sound packs including Cherry MX Blue, buckling spring, zen water drops, and more. No tracking, no premium tiers, 100% free."
|
||||
category: fun
|
||||
tags: ["audio", "mechanical-keyboard", "sound-effects", "typing", "asmr"]
|
||||
|
||||
features:
|
||||
- "16 sound packs (Cherry MX, buckling spring, zen, water drops, and more)"
|
||||
- "Adjustable volume per pack"
|
||||
@@ -62,4 +65,4 @@ events and play audio.
|
||||
|
||||
ThockBoard is 100% free. All 16 sound packs are available to everyone. There are no
|
||||
premium tiers, no locked features, no usage limits. If you enjoy it, you can
|
||||
[buy me a coffee](/coffee) — but there's zero pressure.
|
||||
[buy me a coffee](/support) — but there's zero pressure.
|
||||
@@ -76,7 +76,7 @@ const jsonLdArray = jsonLd
|
||||
|
||||
<nav class="nav-desktop">
|
||||
<a href="/">Extensions</a>
|
||||
<a href="/coffee">Support</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/feedback">Feedback</a>
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">☀</button>
|
||||
</nav>
|
||||
@@ -93,7 +93,7 @@ const jsonLdArray = jsonLd
|
||||
<div class="nav-overlay" id="nav-overlay" aria-hidden="true">
|
||||
<nav class="nav-overlay-menu">
|
||||
<a href="/">Extensions</a>
|
||||
<a href="/coffee">Support</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/feedback">Feedback</a>
|
||||
<button class="theme-toggle theme-toggle-mobile" id="theme-toggle-mobile">☀ Light</button>
|
||||
</nav>
|
||||
@@ -111,7 +111,7 @@ const jsonLdArray = jsonLd
|
||||
Greasy Sprocket — Free Chrome extensions, always.
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/coffee">Support my work</a>
|
||||
<a href="/support">Support my work</a>
|
||||
<a href="/feedback">Leave feedback</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+139
-49
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const extensions = await getCollection('extensions');
|
||||
@@ -13,13 +13,9 @@ export async function getStaticPaths() {
|
||||
const { ext } = Astro.props;
|
||||
const { Content, headings } = await ext.render();
|
||||
|
||||
// Build table of contents from h2 headings only
|
||||
const tocItems = headings
|
||||
.filter((h: any) => h.depth === 2)
|
||||
.map((h: any) => ({
|
||||
text: h.text,
|
||||
slug: h.slug,
|
||||
}));
|
||||
.map((h: any) => ({ text: h.text, slug: h.slug }));
|
||||
|
||||
const siteUrl = 'https://greasysprocket.com';
|
||||
const canonicalUrl = `${siteUrl}/${ext.slug}`;
|
||||
@@ -27,8 +23,16 @@ const ogImage = ext.data.screenshot
|
||||
? new URL(ext.data.screenshot, siteUrl).href
|
||||
: undefined;
|
||||
|
||||
// JSON-LD: SoftwareApplication
|
||||
const softwareAppLd = {
|
||||
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,
|
||||
@@ -36,51 +40,27 @@ const softwareAppLd = {
|
||||
applicationCategory: 'BrowserExtension',
|
||||
operatingSystem: 'Chrome',
|
||||
url: canonicalUrl,
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
||||
...(ext.data.chromeStoreUrl && { installUrl: ext.data.chromeStoreUrl }),
|
||||
};
|
||||
|
||||
// JSON-LD: BreadcrumbList
|
||||
const breadcrumbLd = {
|
||||
},
|
||||
{
|
||||
'@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,
|
||||
},
|
||||
{ '@type': 'ListItem', position: 1, name: 'Extensions', item: siteUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: ext.data.name, item: canonicalUrl },
|
||||
],
|
||||
};
|
||||
|
||||
// JSON-LD: FAQPage (if FAQs exist)
|
||||
const faqLd = ext.data.faq
|
||||
? {
|
||||
},
|
||||
...(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,
|
||||
},
|
||||
acceptedAnswer: { '@type': 'Answer', text: f.answer },
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
}] : []),
|
||||
];
|
||||
---
|
||||
|
||||
<Base
|
||||
@@ -93,9 +73,30 @@ const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
<section class="ext-hero">
|
||||
<div class="container">
|
||||
<div class="info">
|
||||
<div class="section-label">{ext.slug}</div>
|
||||
<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">
|
||||
@@ -125,14 +126,11 @@ const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
<nav>
|
||||
<ol>
|
||||
{tocItems.map((item: any) => (
|
||||
<li>
|
||||
<a href={`#${item.slug}`}>{item.text}</a>
|
||||
</li>
|
||||
<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>
|
||||
<li><a href="#faq">FAQ</a></li>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -152,6 +150,21 @@ const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
<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>
|
||||
@@ -177,4 +190,81 @@ const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
</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>
|
||||
@@ -6,6 +6,14 @@ const extensions = await getCollection('extensions');
|
||||
|
||||
const siteUrl = 'https://greasysprocket.com';
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'productivity': 'Productivity',
|
||||
'developer-tools': 'Developer Tools',
|
||||
'fun': 'Fun',
|
||||
'privacy': 'Privacy',
|
||||
'utilities': 'Utilities',
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
@@ -33,7 +41,7 @@ const jsonLd = {
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#extensions" class="btn btn-primary">Browse extensions</a>
|
||||
<a href="/coffee" class="btn btn-outline">Support the work</a>
|
||||
<a href="/support" class="btn btn-outline">Support the work</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -42,12 +50,33 @@ const jsonLd = {
|
||||
<div class="container">
|
||||
<div class="section-label">Extensions</div>
|
||||
<h2>What I've built so far</h2>
|
||||
<div class="ext-grid">
|
||||
<div class="ext-grid" id="ext-grid">
|
||||
{extensions.map((ext) => (
|
||||
<article class="ext-card">
|
||||
<span class="ext-tag">{ext.slug}</span>
|
||||
<article class="ext-card" data-ext={ext.slug}>
|
||||
<div class="ext-card-header">
|
||||
<span class="ext-tag ext-tag-category" data-category={ext.data.category}>
|
||||
{categoryLabels[ext.data.category] || ext.data.category}
|
||||
</span>
|
||||
<div class="ext-rating ext-rating-display" aria-label="Loading ratings...">
|
||||
<span class="ext-stars ext-stars-placeholder">
|
||||
<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">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{ext.data.name}</h3>
|
||||
<p>{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="links">
|
||||
<a href={`/${ext.slug}`}>Learn more →</a>
|
||||
{ext.data.chromeStoreUrl && (
|
||||
@@ -68,10 +97,49 @@ const jsonLd = {
|
||||
<p>
|
||||
Useful tools shouldn't be locked behind paywalls. Every extension is
|
||||
fully featured — no premium tiers, no trial periods, no locked features.
|
||||
If you want to support development, you can <a href="/coffee">buy me a coffee</a>.
|
||||
If you want to support development, you can <a href="/support">buy me a coffee</a>.
|
||||
There's zero pressure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script is:inline>
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/ratings');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const ratingsMap = {};
|
||||
for (const r of data) {
|
||||
ratingsMap[r.ext] = r;
|
||||
}
|
||||
document.querySelectorAll('.ext-card[data-ext]').forEach((card) => {
|
||||
const ext = card.dataset.ext;
|
||||
const r = ratingsMap[ext];
|
||||
const ratingDiv = card.querySelector('.ext-rating-display');
|
||||
if (!ratingDiv) return;
|
||||
if (!r || r.count === 0) {
|
||||
ratingDiv.querySelector('.ext-stars-placeholder').innerHTML =
|
||||
'<span class="star star-empty">★</span>'.repeat(5);
|
||||
ratingDiv.querySelector('.ext-rating-count').textContent = 'No ratings yet';
|
||||
return;
|
||||
}
|
||||
const avg = r.average;
|
||||
const full = Math.floor(avg);
|
||||
const hasHalf = avg % 1 >= 0.25 && avg % 1 < 0.75;
|
||||
const empty = 5 - full - (hasHalf ? 1 : 0);
|
||||
let stars = '';
|
||||
for (let i = 0; i < full; i++) stars += '<span class="star star-full">★</span>';
|
||||
if (hasHalf) stars += '<span class="star star-half">★</span>';
|
||||
for (let i = 0; i < empty; i++) stars += '<span class="star star-empty">★</span>';
|
||||
ratingDiv.querySelector('.ext-stars-placeholder').innerHTML = stars;
|
||||
ratingDiv.querySelector('.ext-stars-placeholder').classList.remove('ext-stars-placeholder');
|
||||
ratingDiv.querySelector('.ext-rating-count').textContent = `${avg.toFixed(1)} (${r.count})`;
|
||||
});
|
||||
} catch (e) {
|
||||
// Silent fail — ratings are best-effort
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
@@ -6,10 +6,11 @@ import Base from '../layouts/Base.astro';
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<span class="eyebrow">no pressure · everything is free</span>
|
||||
<h1>Buy me a coffee</h1>
|
||||
<h1>Support the work</h1>
|
||||
<p>
|
||||
If you enjoy my extensions and want to support my work,
|
||||
consider buying me a coffee. Every contribution helps me keep building free tools.
|
||||
All extensions are and will always be 100% free. If you enjoy them and
|
||||
want to support development, here are a few ways. No features are locked
|
||||
behind donations, no premium tiers, no paywalls.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -22,8 +23,8 @@ import Base from '../layouts/Base.astro';
|
||||
<div class="label">Buy me a coffee</div>
|
||||
</a>
|
||||
<p class="coffee-note">
|
||||
All extensions are and will always be 100% free. This is completely voluntary —
|
||||
no features are locked behind donations, and there are no premium tiers.
|
||||
Every contribution helps keep these extensions free and maintained.
|
||||
Completely voluntary — zero pressure, zero guilt.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -470,6 +470,15 @@ a:hover { color: var(--accent-hover); }
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* Card header: category badge + rating */
|
||||
.ext-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ext-card h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
@@ -512,6 +521,198 @@ a:hover { color: var(--accent-hover); }
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Category badge — colored per category */
|
||||
.ext-tag-category {
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.ext-tag-category[data-category="productivity"] {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #60a5fa;
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.ext-tag-category[data-category="developer-tools"] {
|
||||
background: rgba(168, 85, 247, 0.12);
|
||||
color: #c084fc;
|
||||
border-color: rgba(168, 85, 247, 0.25);
|
||||
}
|
||||
|
||||
.ext-tag-category[data-category="fun"] {
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
color: var(--accent);
|
||||
border-color: rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
.ext-tag-category[data-category="privacy"] {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #4ade80;
|
||||
border-color: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.ext-tag-category[data-category="utilities"] {
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #2dd4bf;
|
||||
border-color: rgba(20, 184, 166, 0.25);
|
||||
}
|
||||
|
||||
/* Rating stars */
|
||||
.ext-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ext-stars {
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.star {
|
||||
font-size: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.star-full { color: #fbbf24; }
|
||||
.star-half { color: #fbbf24; opacity: 0.5; }
|
||||
.star-empty { color: var(--text-muted); opacity: 0.4; }
|
||||
|
||||
.ext-rating-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ext-rating-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tag pills */
|
||||
.ext-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
transition: color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.tag-pill:hover {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
|
||||
/* Hero meta (category + rating in landing page) */
|
||||
.ext-hero-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ============ RATING — inline in hero ============ */
|
||||
.ext-rating-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ext-rating-inline .ext-stars {
|
||||
font-size: 14px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* ============ RATING — vote CTA at end of content ============ */
|
||||
.rate-cta {
|
||||
margin-top: 40px;
|
||||
padding: 32px 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rate-cta-inner {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.rate-cta h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rate-cta-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.rate-cta-subtitle.success {
|
||||
color: var(--positive);
|
||||
}
|
||||
|
||||
.rate-cta-subtitle.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.rating-vote-stars {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.star-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.35;
|
||||
padding: 4px;
|
||||
transition: color 150ms ease, opacity 150ms ease, transform 100ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.star-btn:hover,
|
||||
.star-btn.hover {
|
||||
color: #fbbf24;
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.star-btn.selected {
|
||||
color: #fbbf24;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.star-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ============ WHY FREE ============ */
|
||||
.why-free {
|
||||
text-align: center;
|
||||
|
||||
+137
-3
@@ -46,7 +46,7 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create table if not exists
|
||||
// Create tables if not exists
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -54,9 +54,19 @@ func main() {
|
||||
reason TEXT DEFAULT '',
|
||||
text TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ext TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5),
|
||||
ip_hash TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratings_ext ON ratings(ext);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratings_ip ON ratings(ip_hash);
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
log.Fatalf("failed to create table: %v", err)
|
||||
log.Fatalf("failed to create tables: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -154,6 +164,130 @@ func main() {
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
})
|
||||
|
||||
// POST /rate — submit a star rating (1-5) for an extension
|
||||
mux.HandleFunc("/rate", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
var req struct {
|
||||
Ext string `json:"ext"`
|
||||
Rating int `json:"rating"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req.Ext = strings.TrimSpace(req.Ext)
|
||||
if req.Ext == "" {
|
||||
http.Error(w, "ext is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Rating < 1 || req.Rating > 5 {
|
||||
http.Error(w, "rating must be 1-5", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the IP for dedup (one vote per IP per extension)
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
ipHash := fmt.Sprintf("%x", ip)[:16] // simple hash, enough for dedup
|
||||
|
||||
// Check if already voted
|
||||
var existing int
|
||||
err := db.QueryRow("SELECT rating FROM ratings WHERE ext = ? AND ip_hash = ?", req.Ext, ipHash).Scan(&existing)
|
||||
if err == nil {
|
||||
// Update existing vote
|
||||
_, err = db.Exec("UPDATE ratings SET rating = ?, created_at = CURRENT_TIMESTAMP WHERE ext = ? AND ip_hash = ?", req.Rating, req.Ext, ipHash)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Insert new vote
|
||||
_, err = db.Exec("INSERT INTO ratings (ext, rating, ip_hash) VALUES (?, ?, ?)", req.Ext, req.Rating, ipHash)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("rating received: ext=%s rating=%d", req.Ext, req.Rating)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
|
||||
// GET /ratings?ext=<slug> — get aggregate rating for an extension
|
||||
mux.HandleFunc("/ratings", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
ext := strings.TrimSpace(r.URL.Query().Get("ext"))
|
||||
if ext == "" {
|
||||
// Return all ratings
|
||||
rows, err := db.Query(`
|
||||
SELECT ext, AVG(rating) as avg, COUNT(*) as count
|
||||
FROM ratings
|
||||
GROUP BY ext
|
||||
`)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type RatingSummary struct {
|
||||
Ext string `json:"ext"`
|
||||
Average float64 `json:"average"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
var all []RatingSummary
|
||||
for rows.Next() {
|
||||
var s RatingSummary
|
||||
if err := rows.Scan(&s.Ext, &s.Average, &s.Count); err != nil {
|
||||
continue
|
||||
}
|
||||
all = append(all, s)
|
||||
}
|
||||
json.NewEncoder(w).Encode(all)
|
||||
return
|
||||
}
|
||||
|
||||
// Single extension
|
||||
var avg float64
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COALESCE(AVG(rating), 0), COUNT(*) FROM ratings WHERE ext = ?", ext).Scan(&avg, &count)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ext": ext,
|
||||
"average": avg,
|
||||
"count": count,
|
||||
})
|
||||
})
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
Reference in New Issue
Block a user