Compare commits
7 Commits
c5958f283f
...
4d5c69761c
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d5c69761c | |||
| ffabbdde14 | |||
| f2d5e74f19 | |||
| 903e51cf5b | |||
| 0bc8ac75cd | |||
| cb357f2103 | |||
| c52204f57e |
@@ -1,6 +1,6 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: set this to true or false
|
esbuild: true
|
||||||
sharp: set this to true or false
|
sharp: true
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- esbuild
|
- esbuild
|
||||||
- sharp
|
- sharp
|
||||||
@@ -5,10 +5,25 @@ const extensions = defineCollection({
|
|||||||
schema: z.object({
|
schema: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
tagline: 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(),
|
features: z.array(z.string()).optional(),
|
||||||
screenshot: z.string().optional(),
|
screenshot: z.string().optional(),
|
||||||
chromeStoreUrl: z.string().url().optional().or(z.literal('')),
|
chromeStoreUrl: z.string().url().optional().or(z.literal('')),
|
||||||
downloadUrl: z.string().optional(),
|
downloadUrl: z.string().optional(),
|
||||||
|
faq: z.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string(),
|
||||||
|
answer: z.string(),
|
||||||
|
})
|
||||||
|
).optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,108 @@
|
|||||||
|
---
|
||||||
|
name: FormBuddy
|
||||||
|
tagline: "Autosaves everything you type in forms. If the page crashes or reloads, restore with one click."
|
||||||
|
description: "FormBuddy is a free Chrome extension that automatically saves everything you type in web forms. If a page crashes, reloads, or you accidentally close a tab, FormBuddy shows a restore banner when you come back — one click and all your fields are filled in again. Works on every website, excludes banking and payment sites by default, and stores everything locally. No tracking, no premium tiers, 100% free."
|
||||||
|
category: productivity/workflow
|
||||||
|
date: 2026-07-11
|
||||||
|
tags: ["autosave", "forms", "restore", "backup", "productivity", "crash-recovery"]
|
||||||
|
|
||||||
|
features:
|
||||||
|
- "Automatically saves form fields as you type — inputs, textareas, selects"
|
||||||
|
- "Restore banner appears when saved data is detected on the page"
|
||||||
|
- "One-click restore fills all fields back in, even on React/Vue sites"
|
||||||
|
- "Configurable autosave delay (instant, fast, careful, relaxed)"
|
||||||
|
- "Excludes banking, PayPal, and Google accounts by default — add your own"
|
||||||
|
- "Max 10 snapshots per site with automatic oldest-first cleanup"
|
||||||
|
- "MutationObserver catches forms added dynamically after page load"
|
||||||
|
- "100% free, no premium tiers, no ads, no tracking"
|
||||||
|
screenshot: ""
|
||||||
|
chromeStoreUrl: ""
|
||||||
|
downloadUrl: "https://git.greasysprocket.com/monyi/formbuddy/releases"
|
||||||
|
faq:
|
||||||
|
- question: "Is FormBuddy really free?"
|
||||||
|
answer: "Yes, 100% free. All features 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 FormBuddy collect any data?"
|
||||||
|
answer: "No. FormBuddy doesn't collect any data. No analytics, no tracking, no telemetry. All form snapshots are stored locally in your browser via chrome.storage.local. Nothing ever leaves your device or is sent to any server."
|
||||||
|
- question: "How does FormBuddy restore my form data?"
|
||||||
|
answer: "When you visit a page where FormBuddy previously saved form data, a small banner appears in the bottom-right corner showing when the data was last saved. Click Restore and all your fields are filled back in. The restore uses native value setters and dispatches input/change events, so it works even on sites built with React, Vue, or other frameworks."
|
||||||
|
- question: "Does FormBuddy save passwords?"
|
||||||
|
answer: "FormBuddy saves password fields by default so you can recover them after a crash. However, it skips password fields that have autocomplete=off (common on banking and security sites). You can also add any site to the excluded list to prevent FormBuddy from saving anything on that domain."
|
||||||
|
- question: "Can I exclude specific websites?"
|
||||||
|
answer: "Yes. FormBuddy excludes banking sites, PayPal, and Google accounts by default. You can add or remove sites from the excluded list in the popup — just enter one domain per line. FormBuddy will not save or restore anything on excluded sites."
|
||||||
|
- question: "What happens if I have multiple forms on the same site?"
|
||||||
|
answer: "FormBuddy creates a unique signature for each form based on its field names. If you fill out different forms on the same site, each one is saved and restored independently. Up to 10 snapshots per site are kept, with the oldest automatically deleted when the limit is reached."
|
||||||
|
- question: "Will FormBuddy slow down my browser?"
|
||||||
|
answer: "No. FormBuddy uses a debounced save (800ms by default) so it only writes to storage after you stop typing. The content script is lightweight and runs at document_idle. CPU and storage usage are negligible."
|
||||||
|
---
|
||||||
|
|
||||||
|
FormBuddy automatically saves everything you type in web forms. If the page crashes,
|
||||||
|
reloads, or you accidentally close the tab, FormBuddy remembers your input and offers
|
||||||
|
to restore it with a single click when you return.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
When you type in any form field on any website, FormBuddy silently saves your input
|
||||||
|
to local storage. It uses a debounced save — by default, it waits 800ms after you
|
||||||
|
stop typing before writing, so there's no performance impact.
|
||||||
|
|
||||||
|
If you later return to a page where FormBuddy has saved data (e.g. after a crash,
|
||||||
|
reload, or navigating away), a small banner appears in the bottom-right corner:
|
||||||
|
|
||||||
|
> 📋 FormBuddy found your saved form — last saved 2m ago on "Contact Us"
|
||||||
|
> [Restore] [Dismiss]
|
||||||
|
|
||||||
|
Click **Restore** and all your fields are filled back in instantly. The restore
|
||||||
|
uses native value setters and dispatches `input`/`change` events, so it works
|
||||||
|
correctly even on sites built with React, Vue, Svelte, or other modern frameworks.
|
||||||
|
|
||||||
|
## What it saves
|
||||||
|
|
||||||
|
| Field type | Saved? | Notes |
|
||||||
|
|------------|--------|-------|
|
||||||
|
| Text, email, URL, tel, search | ✅ | All text inputs |
|
||||||
|
| Number | ✅ | Numeric inputs |
|
||||||
|
| Password | ✅ | Skipped if `autocomplete=off` (banking) |
|
||||||
|
| Textarea | ✅ | Large text fields, comment boxes |
|
||||||
|
| Select dropdowns | ✅ | Selected option value |
|
||||||
|
| Hidden, submit, button, file | ❌ | Not text content |
|
||||||
|
| Checkbox, radio, range, color | ❌ | Not text content |
|
||||||
|
| Date, time, datetime | ❌ | Not text content |
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
|---------|---------|---|
|
||||||
|
| **Autosave enabled** | ON | Master toggle for the entire extension |
|
||||||
|
| **Show restore prompt** | ON | Display the restore banner when saved data is found |
|
||||||
|
| **Autosave delay** | Fast (0.8s) | How long to wait after you stop typing before saving |
|
||||||
|
| **Excluded sites** | Banking + PayPal + Google | Sites where FormBuddy will not save or restore anything |
|
||||||
|
|
||||||
|
## Excluded sites
|
||||||
|
|
||||||
|
FormBuddy excludes the following sites by default to protect sensitive data:
|
||||||
|
|
||||||
|
- `accounts.google.com`
|
||||||
|
- `paypal.com`
|
||||||
|
- `banking.` (matches any domain containing "banking.")
|
||||||
|
- `secure.` (matches any domain containing "secure.")
|
||||||
|
- `login.microsoftonline.com`
|
||||||
|
|
||||||
|
You can add or remove sites from this list in the popup. Matching is flexible —
|
||||||
|
it checks exact domain, subdomain suffix, and substring inclusion.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
FormBuddy doesn't collect any data. No analytics, no tracking, no telemetry.
|
||||||
|
All form snapshots are stored locally via `chrome.storage.local` — nothing ever
|
||||||
|
leaves your device. The extension only requests `storage` and `local` permissions.
|
||||||
|
No host permissions, no tabs, no browsing history access.
|
||||||
|
|
||||||
|
Form snapshots are keyed by page origin and form signature (a hash of field names).
|
||||||
|
They are never transmitted to any server. You can clear all snapshots at any time
|
||||||
|
from the popup with the "Clear all" button.
|
||||||
|
|
||||||
|
## Free forever
|
||||||
|
|
||||||
|
FormBuddy is 100% free. All features 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.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
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"
|
||||||
|
- "Animated gradient backgrounds that match each soundscape"
|
||||||
|
- "Play, pause, skip, and volume controls"
|
||||||
|
- "100% offline — no streaming, no external services"
|
||||||
|
- "100% free, no premium tiers, no ads, no tracking"
|
||||||
|
screenshot: ""
|
||||||
|
chromeStoreUrl: ""
|
||||||
|
downloadUrl: "https://git.greasysprocket.com/monyi/tabfm/releases"
|
||||||
|
faq:
|
||||||
|
- question: "Is TabFM really free?"
|
||||||
|
answer: "Yes, 100% free. All 15 soundscapes 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 TabFM collect any data?"
|
||||||
|
answer: "No. TabFM doesn't collect any data. No analytics, no tracking, no telemetry. All audio files are bundled with the extension — nothing is downloaded from external servers."
|
||||||
|
- question: "Does TabFM need an internet connection?"
|
||||||
|
answer: "No. All soundscapes are bundled as audio files inside the extension. Once installed, TabFM works completely offline. The only network request is the Google search bar, which goes to Google when you search."
|
||||||
|
- question: "Will TabFM replace my Google search?"
|
||||||
|
answer: "TabFM replaces the new tab page with a custom page that includes a Google search bar. You can still search Google directly from the new tab — the search bar is right there. Everything else (clock, soundscapes) is additive."
|
||||||
|
- question: "How many soundscapes are there?"
|
||||||
|
answer: "15 soundscapes: Rainy Loft, Night Drive, Forest Dawn, Coffee Shop, Ocean Waves, Crackling Fire, Vinyl Crackle, Deep Space, Winter Wind, Summer Night, Vinyl Jazz, Mountain Stream, City Rain, Tibetan Bowl, and Pixel Rain. All free."
|
||||||
|
- question: "Does TabFM autoplay sound when I open a new tab?"
|
||||||
|
answer: "Yes, by default TabFM autoplays the last selected soundscape when you open a new tab. You can pause it with the play/pause button, and the volume is adjustable. Autoplay is enabled because the new tab page is an extension page, not a regular website."
|
||||||
|
---
|
||||||
|
|
||||||
|
TabFM replaces your new tab page with a calm, focused space: a clock, a Google search bar,
|
||||||
|
and 15 ambient soundscapes to set the mood.
|
||||||
|
|
||||||
|
## Soundscapes
|
||||||
|
|
||||||
|
| Vibe | Soundscapes |
|
||||||
|
|------|-------------|
|
||||||
|
| Cozy | Rainy Loft, Coffee Shop, Vinyl Crackle, Vinyl Jazz |
|
||||||
|
| Nature | Forest Dawn, Ocean Waves, Mountain Stream, City Rain |
|
||||||
|
| Focus | Night Drive, Deep Space, Crackling Fire, Pixel Rain |
|
||||||
|
| Calm | Winter Wind, Summer Night, Tibetan Bowl |
|
||||||
|
|
||||||
|
Each soundscape is a 3–5 minute loop, synthesized with DSP (numpy/scipy) and bundled
|
||||||
|
as a compact MP3. No streaming, no external services — everything lives inside the
|
||||||
|
extension.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
When you open a new tab, TabFM loads instantly with the clock, search bar, and player.
|
||||||
|
The last selected soundscape auto-plays (you can pause it). The animated gradient
|
||||||
|
background shifts to match the soundscape's color palette.
|
||||||
|
|
||||||
|
Switch soundscapes from the grid below the player, or use the skip buttons. Volume
|
||||||
|
is adjustable and your preferences are saved across sessions via `chrome.storage.sync`.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
TabFM doesn't collect any data. No analytics, no tracking, no telemetry.
|
||||||
|
All audio files are bundled with the extension — nothing is downloaded from external
|
||||||
|
servers. The only permission requested is `storage` (to remember your volume and
|
||||||
|
selected soundscape).
|
||||||
|
|
||||||
|
## Free forever
|
||||||
|
|
||||||
|
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](/support) — but there's zero pressure.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
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"
|
||||||
|
- "Utilities: Reverse, Trim, Remove line breaks"
|
||||||
|
- "Popup: paste, transform, copy — one click"
|
||||||
|
- "Context menu: select text → right-click → Text Tools → auto-copies"
|
||||||
|
- "100% free, no premium, no ads, no tracking"
|
||||||
|
screenshot: ""
|
||||||
|
chromeStoreUrl: ""
|
||||||
|
downloadUrl: "https://git.greasysprocket.com/monyi/text-tools/releases"
|
||||||
|
faq:
|
||||||
|
- question: "Is Text Tools really free?"
|
||||||
|
answer: "Yes, 100% free. All 12 transforms 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 Text Tools collect any data?"
|
||||||
|
answer: "No. Text Tools doesn't collect any data. No analytics, no tracking, no telemetry. Everything runs locally in your browser. Nothing is sent to any server."
|
||||||
|
- question: "How do I use Text Tools?"
|
||||||
|
answer: "Two ways: (1) Click the toolbar icon to open the popup, paste text, pick a transform, and copy. (2) Select text on any page, right-click, go to Text Tools, and pick a transform — the result is automatically copied to your clipboard."
|
||||||
|
- question: "What text transforms are available?"
|
||||||
|
answer: "12 transforms across three categories: Case (UPPERCASE, lowercase, Title Case, Sentence case), Code (camelCase, PascalCase, kebab-case, snake_case, CONSTANT_CASE), and Utility (Reverse, Trim, Remove line breaks)."
|
||||||
|
- question: "Does Text Tools work offline?"
|
||||||
|
answer: "Yes. Text Tools works entirely offline. All processing happens locally in your browser. No internet connection is needed after installation."
|
||||||
|
- question: "Can I use Text Tools on any website?"
|
||||||
|
answer: "Yes. The context menu works on any website where you can select text. The popup works anywhere — just click the toolbar icon, paste, and transform."
|
||||||
|
---
|
||||||
|
|
||||||
|
Text Tools works two ways:
|
||||||
|
|
||||||
|
1. **Popup** — click the toolbar icon, paste text, pick a transform, copy.
|
||||||
|
2. **Context menu** — select text on any page, right-click → Text Tools → transform → copies to clipboard automatically.
|
||||||
|
|
||||||
|
## All transforms
|
||||||
|
|
||||||
|
| Category | Transforms |
|
||||||
|
|----------|-----------|
|
||||||
|
| Case | UPPERCASE, lowercase, Title Case, Sentence case |
|
||||||
|
| Code | camelCase, PascalCase, kebab-case, snake_case, CONSTANT_CASE |
|
||||||
|
| Utility | Reverse, Trim, Remove line breaks |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Text Tools runs entirely in your browser. When you select text and right-click, the
|
||||||
|
extension reads your selection, applies the transform, and writes the result to your
|
||||||
|
clipboard — all locally, no server round-trip. The popup works the same way: paste,
|
||||||
|
pick a transform, and the result is ready to copy.
|
||||||
|
|
||||||
|
The extension uses standard Clipboard API and context menu APIs. No background scripts
|
||||||
|
that phone home, no analytics, no telemetry.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Text Tools doesn't collect any data. No analytics, no tracking, no telemetry.
|
||||||
|
Everything runs locally in your browser. Nothing is sent to any server.
|
||||||
|
|
||||||
|
## Free forever
|
||||||
|
|
||||||
|
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](/support) — but there's zero pressure.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
---
|
---
|
||||||
name: ThockBoard
|
name: ThockBoard
|
||||||
tagline: Mechanical keyboard sounds for every keystroke. 16 sound packs, zero setup.
|
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:
|
features:
|
||||||
- "16 sound packs (Cherry MX, buckling spring, zen, water drops, and more)"
|
- "16 sound packs (Cherry MX, buckling spring, zen, water drops, and more)"
|
||||||
- "Adjustable volume per pack"
|
- "Adjustable volume per pack"
|
||||||
@@ -10,6 +14,19 @@ features:
|
|||||||
screenshot: "/images/thockboard-preview.png"
|
screenshot: "/images/thockboard-preview.png"
|
||||||
chromeStoreUrl: ""
|
chromeStoreUrl: ""
|
||||||
downloadUrl: "https://git.d0a1.es/d0a1/thockboard/releases"
|
downloadUrl: "https://git.d0a1.es/d0a1/thockboard/releases"
|
||||||
|
faq:
|
||||||
|
- question: "Is ThockBoard really free?"
|
||||||
|
answer: "Yes, 100% free. All 16 sound packs 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 ThockBoard collect any data?"
|
||||||
|
answer: "No. ThockBoard doesn't collect any data. No analytics, no tracking, no telemetry. Sound files are bundled with the extension — nothing is downloaded from external servers."
|
||||||
|
- question: "Will ThockBoard slow down my browser?"
|
||||||
|
answer: "No. ThockBoard uses the Web Audio API to play short sound clips on keystroke. The audio engine is lightweight and only active when typing. CPU usage is negligible."
|
||||||
|
- question: "Can I use ThockBoard on any website?"
|
||||||
|
answer: "Yes. ThockBoard works on any website without configuration. Just install it, pick a sound pack, and start typing."
|
||||||
|
- question: "How do I change the sound pack or volume?"
|
||||||
|
answer: "Click the ThockBoard icon in your Chrome toolbar to open the popup. From there you can select any of the 16 sound packs and adjust the volume slider."
|
||||||
|
- question: "Does it work with other browsers like Firefox or Edge?"
|
||||||
|
answer: "ThockBoard is built for Chrome and Chromium-based browsers (Edge, Brave, Arc). A Firefox version is not planned but the extension uses standard WebExtensions APIs, so it could work with minor adjustments."
|
||||||
---
|
---
|
||||||
|
|
||||||
ThockBoard adds satisfying mechanical keyboard sounds to every keystroke, on any website.
|
ThockBoard adds satisfying mechanical keyboard sounds to every keystroke, on any website.
|
||||||
@@ -27,13 +44,25 @@ for realistic, non-repetitive audio.
|
|||||||
| Zen | Water Drop, Bamboo, Singing Bowl, Crystal |
|
| Zen | Water Drop, Bamboo, Singing Bowl, Crystal |
|
||||||
| Fun | Bubble, Soft Tap, Pop, Mechanical Click, Rain |
|
| Fun | Bubble, Soft Tap, Pop, Mechanical Click, Rain |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
ThockBoard listens to keyboard events on whatever page you're typing on — Google Docs,
|
||||||
|
GitHub, Twitter, a plain textarea — and plays a short audio clip for each keystroke.
|
||||||
|
The clips are pre-loaded from bundled audio files, so there's no network request on every
|
||||||
|
key press. The Web Audio API handles playback with near-zero latency.
|
||||||
|
|
||||||
|
You pick a sound pack and set the volume from the popup. That's it. No settings page,
|
||||||
|
no account, no configuration file.
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
ThockBoard doesn't collect any data. No analytics, no tracking, no telemetry.
|
ThockBoard doesn't collect any data. No analytics, no tracking, no telemetry.
|
||||||
Sound files are bundled with the extension — nothing is downloaded from external servers.
|
Sound files are bundled with the extension — nothing is downloaded from external servers.
|
||||||
|
The extension doesn't request any permissions beyond what's needed to listen to keyboard
|
||||||
|
events and play audio.
|
||||||
|
|
||||||
## Free forever
|
## Free forever
|
||||||
|
|
||||||
ThockBoard is 100% free. All 16 sound packs are available to everyone. There are no
|
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
|
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.
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
---
|
---
|
||||||
|
import '../styles/global.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
canonical?: string;
|
||||||
|
ogImage?: string;
|
||||||
|
jsonLd?: object | object[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description = 'Free Chrome extensions — no limits, no catch.' } = Astro.props;
|
const {
|
||||||
|
title,
|
||||||
|
description = 'Free Chrome extensions — no limits, no catch.',
|
||||||
|
canonical,
|
||||||
|
ogImage,
|
||||||
|
jsonLd,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const siteUrl = 'https://greasysprocket.com';
|
||||||
|
const canonicalUrl = canonical || new URL(Astro.url.pathname, siteUrl).href;
|
||||||
|
const ogImageUrl = ogImage
|
||||||
|
? new URL(ogImage, siteUrl).href
|
||||||
|
: new URL('/favicon.svg', siteUrl).href;
|
||||||
|
|
||||||
|
const jsonLdArray = jsonLd
|
||||||
|
? Array.isArray(jsonLd) ? jsonLd : [jsonLd]
|
||||||
|
: [];
|
||||||
---
|
---
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -13,33 +34,151 @@ const { title, description = 'Free Chrome extensions — no limits, no catch.' }
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
<meta name="theme-color" content="#0a0a0b" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
||||||
|
<!-- Canonical -->
|
||||||
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Greasy Sprocket" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
|
<meta property="og:image" content={ogImageUrl} />
|
||||||
|
<meta property="og:image:alt" content={title} />
|
||||||
|
|
||||||
|
<!-- Twitter Cards -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={ogImageUrl} />
|
||||||
|
|
||||||
|
<!-- Preconnect -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data -->
|
||||||
|
{jsonLdArray.map((schema) => (
|
||||||
|
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
|
||||||
|
))}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header" id="site-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="/" class="logo">⚙️ <span>Greasy Sprocket</span></a>
|
<a href="/" class="logo">
|
||||||
<nav>
|
<span class="logo-icon">⚙</span>
|
||||||
|
<span class="logo-text">Greasy Sprocket</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="nav-desktop">
|
||||||
<a href="/">Extensions</a>
|
<a href="/">Extensions</a>
|
||||||
<a href="/coffee">☕ Support</a>
|
<a href="/support">Support</a>
|
||||||
<a href="/feedback">Feedback</a>
|
<a href="/feedback">Feedback</a>
|
||||||
|
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">☀</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<button class="burger" id="burger" aria-label="Menu" aria-expanded="false">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Overlay menu: fixed panel that slides over content -->
|
||||||
|
<div class="nav-overlay" id="nav-overlay" aria-hidden="true">
|
||||||
|
<nav class="nav-overlay-menu">
|
||||||
|
<a href="/">Extensions</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>
|
||||||
|
</div>
|
||||||
|
<div class="nav-overlay-backdrop" id="nav-overlay-backdrop"></div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p>
|
<div class="footer-brand">
|
||||||
Greasy Sprocket — Free Chrome extensions, always.<br />
|
<span class="logo-icon">⚙</span>
|
||||||
<a href="/coffee">Support my work ☕</a> ·
|
Greasy Sprocket — Free Chrome extensions, always.
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="/support">Support my work</a>
|
||||||
<a href="/feedback">Leave feedback</a>
|
<a href="/feedback">Leave feedback</a>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
// Theme
|
||||||
|
const saved = localStorage.getItem('gs-theme');
|
||||||
|
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
const setThemeIcon = (el) => {
|
||||||
|
const theme = document.documentElement.getAttribute('data-theme');
|
||||||
|
if (el) el.textContent = theme === 'dark' ? '☀' : '☾';
|
||||||
|
};
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme');
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('gs-theme', next);
|
||||||
|
document.querySelectorAll('#theme-toggle, #theme-toggle-mobile').forEach(setThemeIcon);
|
||||||
|
};
|
||||||
|
document.querySelectorAll('#theme-toggle, #theme-toggle-mobile').forEach(setThemeIcon);
|
||||||
|
document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
|
||||||
|
document.getElementById('theme-toggle-mobile')?.addEventListener('click', toggleTheme);
|
||||||
|
|
||||||
|
// Burger menu — overlay panel
|
||||||
|
const burger = document.getElementById('burger');
|
||||||
|
const overlay = document.getElementById('nav-overlay');
|
||||||
|
const backdrop = document.getElementById('nav-overlay-backdrop');
|
||||||
|
const header = document.getElementById('site-header');
|
||||||
|
let menuOpen = false;
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
menuOpen = false;
|
||||||
|
burger.setAttribute('aria-expanded', 'false');
|
||||||
|
overlay.setAttribute('aria-hidden', 'true');
|
||||||
|
backdrop.setAttribute('aria-hidden', 'true');
|
||||||
|
header.classList.remove('menu-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
menuOpen = true;
|
||||||
|
burger.setAttribute('aria-expanded', 'true');
|
||||||
|
overlay.setAttribute('aria-hidden', 'false');
|
||||||
|
backdrop.setAttribute('aria-hidden', 'false');
|
||||||
|
header.classList.add('menu-open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
};
|
||||||
|
|
||||||
|
burger.addEventListener('click', () => {
|
||||||
|
menuOpen ? closeMenu() : openMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
backdrop.addEventListener('click', closeMenu);
|
||||||
|
|
||||||
|
// Close on link click
|
||||||
|
overlay.querySelectorAll('a').forEach(a => a.addEventListener('click', closeMenu));
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && menuOpen) closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on resize to desktop
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (window.innerWidth >= 768 && menuOpen) closeMenu();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
|
const extensions = await getCollection('extensions');
|
||||||
|
|
||||||
|
// Pick 2 random extensions
|
||||||
|
const shuffled = [...extensions].sort(() => Math.random() - 0.5);
|
||||||
|
const randomExts = shuffled.slice(0, 2);
|
||||||
|
|
||||||
|
const gearSvg = `<svg width="180" height="180" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g class="gear-spin-slow">
|
||||||
|
<path d="M50 8 L55 17 L64 14 L66 23 L75 23 L72 32 L80 36 L73 42 L80 49 L71 53 L73 62 L64 61 L62 70 L53 67 L50 76 L47 67 L38 70 L36 61 L27 62 L29 53 L20 49 L27 42 L20 36 L28 32 L25 23 L34 23 L36 14 L45 17 Z"
|
||||||
|
fill="none" stroke="var(--accent)" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
<circle cx="50" cy="50" r="15" fill="none" stroke="var(--accent)" stroke-width="2" />
|
||||||
|
<circle cx="50" cy="50" r="4" fill="var(--accent)" />
|
||||||
|
</g>
|
||||||
|
<g class="gear-spin-rev">
|
||||||
|
<circle cx="50" cy="50" r="26" fill="none" stroke="var(--border-bright)" stroke-width="1" stroke-dasharray="2 4" />
|
||||||
|
</g>
|
||||||
|
<circle cx="50" cy="76" r="2" fill="var(--accent)" class="gear-bolt" />
|
||||||
|
</svg>`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title="404 — This page stripped a gear | Greasy Sprocket">
|
||||||
|
<section class="error-page">
|
||||||
|
<div class="container error-content">
|
||||||
|
<div class="error-gear" aria-hidden="true" set:html={gearSvg} />
|
||||||
|
|
||||||
|
<span class="error-code">404</span>
|
||||||
|
<h1>This page stripped a gear</h1>
|
||||||
|
<p class="error-msg">
|
||||||
|
The sprocket you're looking for isn't here. Maybe it fell off the bike,
|
||||||
|
maybe it never existed, maybe it's in the workshop getting degreased.
|
||||||
|
Either way — nothing to see here.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<a href="/" class="btn btn-primary">⟵ Back to the garage</a>
|
||||||
|
<a href="/support" class="btn btn-outline">Support the work</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-hint">
|
||||||
|
<code>try { find('/page') } catch (GearStrippedError) { redirect('/') }</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{randomExts.length > 0 && (
|
||||||
|
<div class="error-extras">
|
||||||
|
<p class="error-extras-label">While you're here, check these out</p>
|
||||||
|
<div class="ext-grid">
|
||||||
|
{randomExts.map((ext) => (
|
||||||
|
<article class="ext-card">
|
||||||
|
<span class="ext-tag">{ext.slug}</span>
|
||||||
|
<h3>{ext.data.name}</h3>
|
||||||
|
<p>{ext.data.tagline}</p>
|
||||||
|
<div class="links">
|
||||||
|
<a href={`/${ext.slug}`}>Learn more →</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-page {
|
||||||
|
min-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-gear {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear-spin-slow {
|
||||||
|
transform-origin: 50px 50px;
|
||||||
|
animation: gear-wobble 5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear-spin-rev {
|
||||||
|
transform-origin: 50px 50px;
|
||||||
|
animation: gear-wobble-rev 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear-bolt {
|
||||||
|
animation: bolt-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gear-wobble {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
20% { transform: rotate(-15deg); }
|
||||||
|
40% { transform: rotate(10deg); }
|
||||||
|
60% { transform: rotate(-6deg); }
|
||||||
|
80% { transform: rotate(4deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gear-wobble-rev {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(-360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bolt-pulse {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: left;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-extras {
|
||||||
|
margin-top: 40px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-extras-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 520px) {
|
||||||
|
.ext-grid { grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-card:hover {
|
||||||
|
border-color: var(--border-bright);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-card:hover::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-card h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext-card .links a {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.error-code { font-size: 80px; }
|
||||||
|
.error-page h1 { font-size: 32px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
|
||||||
|
const gearSvg = `<svg width="180" height="180" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g class="gear-spin-fast">
|
||||||
|
<path d="M50 8 L55 17 L64 14 L66 23 L75 23 L72 32 L80 36 L73 42 L80 49 L71 53 L73 62 L64 61 L62 70 L53 67 L50 76 L47 67 L38 70 L36 61 L27 62 L29 53 L20 49 L27 42 L20 36 L28 32 L25 23 L34 23 L36 14 L45 17 Z"
|
||||||
|
fill="none" stroke="var(--accent)" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
<circle cx="50" cy="50" r="15" fill="none" stroke="var(--accent)" stroke-width="2" />
|
||||||
|
<circle cx="50" cy="50" r="4" fill="var(--accent)" />
|
||||||
|
</g>
|
||||||
|
<path d="M50 50 L70 28 L74 12" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.5" />
|
||||||
|
<path d="M50 50 L24 34 L14 18" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.5" />
|
||||||
|
<path d="M50 50 L60 78 L54 94" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.3" />
|
||||||
|
<circle cx="78" cy="16" r="2" fill="var(--accent)" class="spark spark-1" />
|
||||||
|
<circle cx="14" cy="22" r="2" fill="var(--accent)" class="spark spark-2" />
|
||||||
|
<circle cx="82" cy="72" r="2" fill="var(--accent)" class="spark spark-3" />
|
||||||
|
<circle cx="20" cy="82" r="1.5" fill="var(--accent)" class="spark spark-4" />
|
||||||
|
<circle cx="66" cy="88" r="1.5" fill="var(--accent)" class="spark spark-5" />
|
||||||
|
</svg>`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title="500 — The workshop is on fire | Greasy Sprocket">
|
||||||
|
<section class="error-page">
|
||||||
|
<div class="container error-content">
|
||||||
|
<div class="error-gear" aria-hidden="true" set:html={gearSvg} />
|
||||||
|
|
||||||
|
<span class="error-code">500</span>
|
||||||
|
<h1>The workshop caught fire</h1>
|
||||||
|
<p class="error-msg">
|
||||||
|
Something went very wrong on our end. The greased sprocket overheated,
|
||||||
|
seized up, and threw a spark. We're in the back with a fire extinguisher
|
||||||
|
— give us a moment and try again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<a href="/" class="btn btn-primary">⟵ Back to safety</a>
|
||||||
|
<a href="/feedback" class="btn btn-outline">Report what happened</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-hint">
|
||||||
|
<code>while (engine.running) { lubricate(gear); } // TODO: buy fire extinguisher</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-page {
|
||||||
|
min-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-gear {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear-spin-fast {
|
||||||
|
transform-origin: 50px 50px;
|
||||||
|
animation: gear-seize 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gear-seize {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
12% { transform: rotate(28deg); }
|
||||||
|
18% { transform: rotate(24deg); }
|
||||||
|
24% { transform: rotate(38deg); }
|
||||||
|
30% { transform: rotate(35deg); }
|
||||||
|
48% { transform: rotate(35deg); }
|
||||||
|
54% { transform: rotate(8deg); }
|
||||||
|
60% { transform: rotate(14deg); }
|
||||||
|
75% { transform: rotate(5deg); }
|
||||||
|
100% { transform: rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark {
|
||||||
|
animation: spark-blink 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.spark-1 { animation-delay: 0s; }
|
||||||
|
.spark-2 { animation-delay: 0.18s; }
|
||||||
|
.spark-3 { animation-delay: 0.36s; }
|
||||||
|
.spark-4 { animation-delay: 0.54s; }
|
||||||
|
.spark-5 { animation-delay: 0.72s; }
|
||||||
|
|
||||||
|
@keyframes spark-blink {
|
||||||
|
0%, 100% { opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: left;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.error-code { font-size: 80px; }
|
||||||
|
.error-page h1 { font-size: 32px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+225
-14
@@ -11,17 +11,93 @@ export async function getStaticPaths() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { ext } = Astro.props;
|
const { ext } = Astro.props;
|
||||||
const { Content } = await ext.render();
|
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} — Greasy Sprocket`} description={ext.data.tagline}>
|
<Base
|
||||||
|
title={`${ext.data.name} — Free Chrome Extension | Greasy Sprocket`}
|
||||||
|
description={ext.data.description}
|
||||||
|
canonical={canonicalUrl}
|
||||||
|
ogImage={ogImage}
|
||||||
|
jsonLd={jsonLd}
|
||||||
|
>
|
||||||
<section class="ext-hero">
|
<section class="ext-hero">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="badge">100% Free</span>
|
<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>
|
<h1>{ext.data.name}</h1>
|
||||||
<p>{ext.data.tagline}</p>
|
<p class="tagline">{ext.data.tagline}</p>
|
||||||
<div style="display:flex;gap:12px">
|
{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 && (
|
{ext.data.chromeStoreUrl && (
|
||||||
<a href={ext.data.chromeStoreUrl} class="btn btn-primary" target="_blank" rel="noopener">
|
<a href={ext.data.chromeStoreUrl} class="btn btn-primary" target="_blank" rel="noopener">
|
||||||
Add to Chrome
|
Add to Chrome
|
||||||
@@ -43,17 +119,152 @@ const { Content } = await ext.render();
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container ext-layout">
|
||||||
<div class="feature-list">
|
{tocItems.length > 0 && (
|
||||||
{ext.data.features?.map((feature: string) => (
|
<aside class="toc">
|
||||||
<div class="feature-item">
|
<div class="section-label">Contents</div>
|
||||||
<h4>✅ {feature}</h4>
|
<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>
|
|
||||||
<div style="margin-top:32px">
|
|
||||||
<Content />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</Base>
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from '../layouts/Base.astro';
|
|
||||||
---
|
|
||||||
|
|
||||||
<Base title="Support Greasy Sprocket ☕">
|
|
||||||
<section class="hero">
|
|
||||||
<div class="container">
|
|
||||||
<span class="badge">No pressure · Everything is free</span>
|
|
||||||
<h1>Buy me a coffee ☕</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 for everyone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="container" style="text-align:center">
|
|
||||||
<div class="coffee-options">
|
|
||||||
<a href="https://ko-fi.com/monyi" class="coffee-btn" target="_blank" rel="noopener">
|
|
||||||
☕<br/>Ko-fi<span>Buy me a coffee</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p style="color:var(--text-muted);max-width:480px;margin:24px auto 0">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Base>
|
|
||||||
@@ -5,47 +5,53 @@ const extSlug = Astro.url.searchParams.get('ext') || '';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Base title="Feedback — Greasy Sprocket">
|
<Base title="Feedback — Greasy Sprocket">
|
||||||
<section class="hero">
|
<section class="hero" style="padding-bottom: 32px;">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Tell us why you left</h1>
|
<span class="eyebrow">feedback</span>
|
||||||
|
<h1>Share your thoughts</h1>
|
||||||
<p>Your feedback helps us improve. No email required, no account needed.</p>
|
<p>Your feedback helps us improve. No email required, no account needed.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section" style="padding-top: 0;">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div id="feedback-form" class="feedback-form">
|
<div class="feedback-wrap">
|
||||||
<form id="uninstall-feedback">
|
<div id="feedback-form" class="feedback-form-card">
|
||||||
<input type="hidden" name="ext" value={extSlug} />
|
<form id="uninstall-feedback">
|
||||||
|
<input type="hidden" name="ext" value={extSlug} />
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="reason">Why did you uninstall? <span style="color:var(--text-muted)">(optional)</span></label>
|
<label for="reason">How is it going? <span class="optional">(optional)</span></label>
|
||||||
<select name="reason" id="reason">
|
<select name="reason" id="reason">
|
||||||
<option value="">Select a reason...</option>
|
<option value="">Select a reason...</option>
|
||||||
<option value="not-needed">Didn't need it</option>
|
<option value="love-it">Love it!</option>
|
||||||
<option value="too-many">Too many extensions</option>
|
<option value="works-fine">Works as expected</option>
|
||||||
<option value="bugs">Bugs or issues</option>
|
<option value="not-needed">Don't need it</option>
|
||||||
<option value="alternative">Prefer an alternative</option>
|
<option value="too-many">Too many extensions</option>
|
||||||
<option value="other">Other</option>
|
<option value="bugs">Bugs or issues</option>
|
||||||
</select>
|
<option value="alternative">Prefer an alternative</option>
|
||||||
</div>
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="text">Anything else? <span style="color:var(--text-muted)">(optional)</span></label>
|
<label for="text">Anything else? <span class="optional">(optional)</span></label>
|
||||||
<textarea name="text" id="text" placeholder="Tell us more..." maxlength="500"></textarea>
|
<textarea name="text" id="text" placeholder="Tell us more..." maxlength="500"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a href="/" class="btn btn-outline">No thanks</a>
|
<a href="/" class="btn btn-outline">No thanks</a>
|
||||||
<button type="submit" class="btn btn-primary">Send feedback</button>
|
<button type="submit" class="btn btn-primary">Send feedback</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="feedback-success" class="form-success" hidden>
|
<div id="feedback-success" class="form-success" hidden>
|
||||||
<h2>✅ Thanks for your feedback!</h2>
|
<div class="success-icon">✓</div>
|
||||||
<p>We appreciate you taking the time. All our extensions are free — feel free to come back anytime.</p>
|
<h2>Thanks for your feedback</h2>
|
||||||
<a href="/" class="btn btn-outline" style="margin-top:24px">Back to home</a>
|
<p>Thanks for taking the time. All our extensions are free — feel free to reach out anytime.</p>
|
||||||
|
<a href="/" class="btn btn-outline" style="margin-top:24px">Back to home</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+109
-18
@@ -3,36 +3,84 @@ import Base from '../layouts/Base.astro';
|
|||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
const extensions = await getCollection('extensions');
|
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',
|
||||||
|
itemListElement: extensions.map((ext, i) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: i + 1,
|
||||||
|
name: ext.data.name,
|
||||||
|
url: `${siteUrl}/${ext.slug}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base title="Greasy Sprocket — Free Chrome Extensions">
|
<Base
|
||||||
|
title="Greasy Sprocket — Free Chrome Extensions, No Limits"
|
||||||
|
description="Free Chrome extensions that just work. No premium tiers, no nag screens, no tracking. 100% free forever."
|
||||||
|
jsonLd={jsonLd}
|
||||||
|
>
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span class="badge">100% Free · No limits · No catch</span>
|
<span class="eyebrow">100% free · no limits · no catch</span>
|
||||||
<h1>Chrome extensions that just work</h1>
|
<h1>Chrome extensions<br/>that just work</h1>
|
||||||
<p>
|
<p>
|
||||||
We build small, useful browser extensions. They're free, they'll always be free,
|
Small, useful browser extensions. Free forever, no premium tiers,
|
||||||
and they'll never nag you for money. If you love them, you can buy us a coffee.
|
no nag screens. If you love them, buy me a coffee.
|
||||||
</p>
|
</p>
|
||||||
<div style="display:flex;gap:12px;justify-content:center">
|
<div class="hero-actions">
|
||||||
<a href="#extensions" class="btn btn-primary">Browse extensions</a>
|
<a href="#extensions" class="btn btn-primary">Browse extensions</a>
|
||||||
<a href="/coffee" class="btn btn-outline">☕ Support us</a>
|
<a href="/support" class="btn btn-outline">Support the work</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section" id="extensions">
|
<section class="section" id="extensions">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Extensions</h2>
|
<div class="section-label">Extensions</div>
|
||||||
<div class="ext-grid">
|
<h2>What I've built so far</h2>
|
||||||
|
<div class="ext-grid" id="ext-grid">
|
||||||
{extensions.map((ext) => (
|
{extensions.map((ext) => (
|
||||||
<article class="ext-card">
|
<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>
|
<h3>{ext.data.name}</h3>
|
||||||
<p>{ext.data.tagline}</p>
|
<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">
|
<div class="links">
|
||||||
<a href={`/${ext.slug}`}>Learn more →</a>
|
<a href={`/${ext.slug}`}>Learn more →</a>
|
||||||
{ext.data.chromeStoreUrl && (
|
{ext.data.chromeStoreUrl && (
|
||||||
<a href={ext.data.chromeStoreUrl} target="_blank" rel="noopener">Chrome Store</a>
|
<a href={ext.data.chromeStoreUrl} target="_blank" rel="noopener">Add to Chrome</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -42,13 +90,56 @@ const extensions = await getCollection('extensions');
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container" style="text-align:center">
|
<div class="container">
|
||||||
<h2>Why free?</h2>
|
<div class="why-free">
|
||||||
<p style="color:var(--text-muted);max-width:520px;margin:0 auto">
|
<div class="section-label">The model</div>
|
||||||
We believe useful tools shouldn't be locked behind paywalls. Every extension is
|
<h2>Why free?</h2>
|
||||||
fully featured, no premium tiers, no trial periods. If you want to support development,
|
<p>
|
||||||
you can <a href="/coffee">buy us a coffee</a> — but there's zero pressure.
|
Useful tools shouldn't be locked behind paywalls. Every extension is
|
||||||
</p>
|
fully featured — no premium tiers, no trial periods, no locked features.
|
||||||
|
If you want to support development, you can <a href="/support">buy me a coffee</a>.
|
||||||
|
There's zero pressure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</Base>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title="Support Greasy Sprocket">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow">no pressure · everything is free</span>
|
||||||
|
<h1>Support the work</h1>
|
||||||
|
<p>
|
||||||
|
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>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container coffee-page">
|
||||||
|
<a href="https://ko-fi.com/monyi" class="coffee-card" target="_blank" rel="noopener">
|
||||||
|
<div class="coffee-icon">☕</div>
|
||||||
|
<div class="amount">Ko-fi</div>
|
||||||
|
<div class="label">Buy me a coffee</div>
|
||||||
|
</a>
|
||||||
|
<p class="coffee-note">
|
||||||
|
Every contribution helps keep these extensions free and maintained.
|
||||||
|
Completely voluntary — zero pressure, zero guilt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
+1103
-200
File diff suppressed because it is too large
Load Diff
+141
-5
@@ -46,7 +46,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
// Create table if not exists
|
// Create tables if not exists
|
||||||
schema := `
|
schema := `
|
||||||
CREATE TABLE IF NOT EXISTS feedback (
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -54,9 +54,19 @@ func main() {
|
|||||||
reason TEXT DEFAULT '',
|
reason TEXT DEFAULT '',
|
||||||
text TEXT DEFAULT '',
|
text TEXT DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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 {
|
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()
|
mux := http.NewServeMux()
|
||||||
@@ -154,6 +164,130 @@ func main() {
|
|||||||
json.NewEncoder(w).Encode(stats)
|
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
|
// Health check
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -174,7 +308,9 @@ func main() {
|
|||||||
|
|
||||||
func sendDiscordNotification(webhookURL string, req FeedbackRequest) {
|
func sendDiscordNotification(webhookURL string, req FeedbackRequest) {
|
||||||
reasonLabels := map[string]string{
|
reasonLabels := map[string]string{
|
||||||
"not-needed": "Didn't need it",
|
"love-it": "Love it!",
|
||||||
|
"works-fine": "Works as expected",
|
||||||
|
"not-needed": "Don't need it",
|
||||||
"too-many": "Too many extensions",
|
"too-many": "Too many extensions",
|
||||||
"bugs": "Bugs or issues",
|
"bugs": "Bugs or issues",
|
||||||
"alternative": "Prefer an alternative",
|
"alternative": "Prefer an alternative",
|
||||||
@@ -203,7 +339,7 @@ func sendDiscordNotification(webhookURL string, req FeedbackRequest) {
|
|||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"embeds": []map[string]interface{}{
|
"embeds": []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"title": "📋 Uninstall Feedback",
|
"title": "📋 Feedback",
|
||||||
"description": description,
|
"description": description,
|
||||||
"color": 0xf97316,
|
"color": 0xf97316,
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
|||||||
Reference in New Issue
Block a user