feat: add TabFM extension page + pending website updates
- Add TabFM extension page (tabfm.md) with 15 soundscapes, FAQ, privacy section - Update content schema, Base layout, slug page, styles (pending changes) - Deploy to greasysprocket.com via rsync
This commit is contained in:
@@ -5,10 +5,17 @@ const extensions = defineCollection({
|
|||||||
schema: z.object({
|
schema: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
tagline: z.string(),
|
tagline: z.string(),
|
||||||
|
description: z.string(),
|
||||||
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,66 @@
|
|||||||
|
---
|
||||||
|
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."
|
||||||
|
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](/coffee) — but there's zero pressure.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: Text Tools
|
name: Text Tools
|
||||||
tagline: Convert text instantly — UPPERCASE, lowercase, camelCase, kebab-case, and 9 more. Popup or right-click. Free.
|
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."
|
||||||
features:
|
features:
|
||||||
- "12 transforms: UPPERCASE, lowercase, Title Case, Sentence case"
|
- "12 transforms: UPPERCASE, lowercase, Title Case, Sentence case"
|
||||||
- "Code cases: camelCase, PascalCase, kebab-case, snake_case, CONSTANT_CASE"
|
- "Code cases: camelCase, PascalCase, kebab-case, snake_case, CONSTANT_CASE"
|
||||||
@@ -11,6 +12,19 @@ features:
|
|||||||
screenshot: ""
|
screenshot: ""
|
||||||
chromeStoreUrl: ""
|
chromeStoreUrl: ""
|
||||||
downloadUrl: "https://git.greasysprocket.com/monyi/text-tools/releases"
|
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:
|
Text Tools works two ways:
|
||||||
@@ -26,6 +40,16 @@ Text Tools works two ways:
|
|||||||
| Code | camelCase, PascalCase, kebab-case, snake_case, CONSTANT_CASE |
|
| Code | camelCase, PascalCase, kebab-case, snake_case, CONSTANT_CASE |
|
||||||
| Utility | Reverse, Trim, Remove line breaks |
|
| 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
|
## Privacy
|
||||||
|
|
||||||
Text Tools doesn't collect any data. No analytics, no tracking, no telemetry.
|
Text Tools doesn't collect any data. No analytics, no tracking, no telemetry.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
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."
|
||||||
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 +11,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,10 +41,22 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,28 @@ 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>
|
||||||
@@ -17,9 +36,35 @@ const { title, description = 'Free Chrome extensions — no limits, no catch.' }
|
|||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<meta name="theme-color" content="#0a0a0b" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" id="site-header">
|
<header class="site-header" id="site-header">
|
||||||
|
|||||||
+134
-15
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const extensions = await getCollection('extensions');
|
const extensions = await getCollection('extensions');
|
||||||
@@ -11,10 +11,85 @@ export async function getStaticPaths() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { ext } = Astro.props;
|
const { ext } = Astro.props;
|
||||||
const { Content } = await ext.render();
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const siteUrl = 'https://greasysprocket.com';
|
||||||
|
const canonicalUrl = `${siteUrl}/${ext.slug}`;
|
||||||
|
const ogImage = ext.data.screenshot
|
||||||
|
? new URL(ext.data.screenshot, siteUrl).href
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// JSON-LD: SoftwareApplication
|
||||||
|
const softwareAppLd = {
|
||||||
|
'@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 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD: FAQPage (if FAQs exist)
|
||||||
|
const faqLd = 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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||||
---
|
---
|
||||||
|
|
||||||
<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">
|
||||||
@@ -43,19 +118,63 @@ const { Content } = await ext.render();
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container ext-layout">
|
||||||
<div class="section-label">Features</div>
|
{tocItems.length > 0 && (
|
||||||
<div class="feature-list">
|
<aside class="toc">
|
||||||
{ext.data.features?.map((feature: string) => (
|
<div class="section-label">Contents</div>
|
||||||
<div class="feature-item">
|
<nav>
|
||||||
<span class="check">✓</span>
|
<ol>
|
||||||
<h4>{feature}</h4>
|
{tocItems.map((item: any) => (
|
||||||
</div>
|
<li>
|
||||||
))}
|
<a href={`#${item.slug}`}>{item.text}</a>
|
||||||
</div>
|
</li>
|
||||||
<div class="prose">
|
))}
|
||||||
<Content />
|
{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>
|
||||||
</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>
|
||||||
|
)}
|
||||||
</Base>
|
</Base>
|
||||||
@@ -3,9 +3,26 @@ 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 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="eyebrow">100% free · no limits · no catch</span>
|
<span class="eyebrow">100% free · no limits · no catch</span>
|
||||||
|
|||||||
+154
-1
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
html { scroll-behavior: smooth; }
|
html { scroll-behavior: smooth; scroll-padding-top: 80px; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
@@ -531,6 +531,159 @@ a:hover { color: var(--accent-hover); }
|
|||||||
|
|
||||||
.why-free p a { font-weight: 600; }
|
.why-free p a { font-weight: 600; }
|
||||||
|
|
||||||
|
/* ============ EXTENSION LAYOUT (ToC + content) ============ */
|
||||||
|
.ext-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ext-layout {
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table of contents — sticky sidebar */
|
||||||
|
.toc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.toc {
|
||||||
|
display: block;
|
||||||
|
position: sticky;
|
||||||
|
top: 80px;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc nav ol {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc nav li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc nav a {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
padding-left: 12px;
|
||||||
|
transition: color 150ms ease, border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc nav a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ FAQ ============ */
|
||||||
|
.faq-container {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-list {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-card);
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item:hover {
|
||||||
|
border-color: var(--border-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item[open] {
|
||||||
|
border-color: var(--border-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
list-style: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
position: relative;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-toggle::before,
|
||||||
|
.faq-toggle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
background: var(--text-muted);
|
||||||
|
transition: transform 200ms ease, background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-toggle::before {
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-toggle::after {
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item[open] .faq-toggle::after {
|
||||||
|
transform: translateX(-50%) rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item[open] .faq-toggle::before,
|
||||||
|
.faq-item[open] .faq-toggle::after {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ EXTENSION LANDING ============ */
|
/* ============ EXTENSION LANDING ============ */
|
||||||
.ext-hero {
|
.ext-hero {
|
||||||
padding: 40px 0 32px;
|
padding: 40px 0 32px;
|
||||||
|
|||||||
Reference in New Issue
Block a user