feat: add Text Tools extension page

This commit is contained in:
Jose Juan Moñino
2026-07-10 11:11:05 +02:00
parent c5958f283f
commit c52204f57e
9 changed files with 970 additions and 276 deletions
+3 -3
View File
@@ -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
@@ -0,0 +1,35 @@
---
name: Text Tools
tagline: Convert text instantly — UPPERCASE, lowercase, camelCase, kebab-case, and 9 more. One click. Free.
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"
- "One-click copy to clipboard"
- "100% free, no premium, no ads, no tracking"
screenshot: ""
chromeStoreUrl: ""
downloadUrl: "https://git.greasysprocket.com/monyi/text-tools/releases"
---
Text Tools is a popup extension that lives in your toolbar. Click the icon, paste your text,
pick a transform, copy the result. That's it.
## 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 |
## 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](/coffee) — but there's zero pressure.
+102 -8
View File
@@ -1,4 +1,6 @@
--- ---
import '../styles/global.css';
interface Props { interface Props {
title: string; title: string;
description?: string; description?: string;
@@ -13,33 +15,125 @@ 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" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>{title}</title> <title>{title}</title>
</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="/coffee">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="/coffee">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="/coffee">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>
+7 -5
View File
@@ -18,10 +18,10 @@ const { Content } = await ext.render();
<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="section-label">{ext.slug}</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"> <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
@@ -44,14 +44,16 @@ const { Content } = await ext.render();
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-label">Features</div>
<div class="feature-list"> <div class="feature-list">
{ext.data.features?.map((feature: string) => ( {ext.data.features?.map((feature: string) => (
<div class="feature-item"> <div class="feature-item">
<h4>✅ {feature}</h4> <span class="check">✓</span>
<h4>{feature}</h4>
</div> </div>
))} ))}
</div> </div>
<div style="margin-top:32px"> <div class="prose">
<Content /> <Content />
</div> </div>
</div> </div>
+12 -12
View File
@@ -2,26 +2,26 @@
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
--- ---
<Base title="Support Greasy Sprocket"> <Base title="Support Greasy Sprocket">
<section class="hero"> <section class="hero">
<div class="container"> <div class="container">
<span class="badge">No pressure · Everything is free</span> <span class="eyebrow">no pressure · everything is free</span>
<h1>Buy me a coffee</h1> <h1>Buy me a coffee</h1>
<p> <p>
If you enjoy my extensions and want to support my work, consider buying me a coffee. If you enjoy my extensions and want to support my work,
Every contribution helps me keep building free tools for everyone. consider buying me a coffee. Every contribution helps me keep building free tools.
</p> </p>
</div> </div>
</section> </section>
<section class="section"> <section class="section">
<div class="container" style="text-align:center"> <div class="container coffee-page">
<div class="coffee-options"> <a href="https://ko-fi.com/monyi" class="coffee-card" target="_blank" rel="noopener">
<a href="https://ko-fi.com/monyi" class="coffee-btn" target="_blank" rel="noopener"> <div class="coffee-icon">☕</div>
☕<br/>Ko-fi<span>Buy me a coffee</span> <div class="amount">Ko-fi</div>
</a> <div class="label">Buy me a coffee</div>
</div> </a>
<p style="color:var(--text-muted);max-width:480px;margin:24px auto 0"> <p class="coffee-note">
All extensions are and will always be 100% free. This is completely voluntary — 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. no features are locked behind donations, and there are no premium tiers.
</p> </p>
+38 -32
View File
@@ -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">
<label for="reason">Why did you uninstall? <span style="color:var(--text-muted)">(optional)</span></label>
<select name="reason" id="reason">
<option value="">Select a reason...</option>
<option value="not-needed">Didn't need it</option>
<option value="too-many">Too many extensions</option>
<option value="bugs">Bugs or issues</option>
<option value="alternative">Prefer an alternative</option>
<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="reason">How is it going? <span class="optional">(optional)</span></label>
<textarea name="text" id="text" placeholder="Tell us more..." maxlength="500"></textarea> <select name="reason" id="reason">
</div> <option value="">Select a reason...</option>
<option value="love-it">Love it!</option>
<option value="works-fine">Works as expected</option>
<option value="not-needed">Don't need it</option>
<option value="too-many">Too many extensions</option>
<option value="bugs">Bugs or issues</option>
<option value="alternative">Prefer an alternative</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-actions"> <div class="form-group">
<a href="/" class="btn btn-outline">No thanks</a> <label for="text">Anything else? <span class="optional">(optional)</span></label>
<button type="submit" class="btn btn-primary">Send feedback</button> <textarea name="text" id="text" placeholder="Tell us more..." maxlength="500"></textarea>
</div> </div>
</form>
</div>
<div id="feedback-success" class="form-success" hidden> <div class="form-actions">
<h2>✅ Thanks for your feedback!</h2> <a href="/" class="btn btn-outline">No thanks</a>
<p>We appreciate you taking the time. All our extensions are free — feel free to come back anytime.</p> <button type="submit" class="btn btn-primary">Send feedback</button>
<a href="/" class="btn btn-outline" style="margin-top:24px">Back to home</a> </div>
</form>
</div>
<div id="feedback-success" class="form-success" hidden>
<div class="success-icon">✓</div>
<h2>Thanks for your feedback</h2>
<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>
+21 -15
View File
@@ -8,31 +8,33 @@ const extensions = await getCollection('extensions');
<Base title="Greasy Sprocket — Free Chrome Extensions"> <Base title="Greasy Sprocket — Free Chrome Extensions">
<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="/coffee" 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>
<h2>What I've built so far</h2>
<div class="ext-grid"> <div class="ext-grid">
{extensions.map((ext) => ( {extensions.map((ext) => (
<article class="ext-card"> <article class="ext-card">
<span class="ext-tag">{ext.slug}</span>
<h3>{ext.data.name}</h3> <h3>{ext.data.name}</h3>
<p>{ext.data.tagline}</p> <p>{ext.data.tagline}</p>
<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 +44,17 @@ 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="/coffee">buy me a coffee</a>.
There's zero pressure.
</p>
</div>
</div> </div>
</section> </section>
</Base> </Base>
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -174,7 +174,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 +205,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),