fix: update tool count to 51, refresh hero copy and login page

- Update stale tool counts (48/49/50) to 51 across all apps, docs, and tests
- Add Meme Generator to landing page bento grid (was missing)
- Add 12 SEO-friendly rotating phrases to hero typing cursor
- Change landing hero subtitle to "Every image tool you need."
- Replace outdated login page copy with branded hero + rotating phrases
This commit is contained in:
SnapOtter
2026-05-10 22:18:11 +08:00
parent 32e7b03ab4
commit 172c0fa772
22 changed files with 125 additions and 52 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
lines.push(`# ${spec.info.title}`);
lines.push("");
lines.push(
"> Self-hosted image processing API with 50 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
"> Self-hosted image processing API with 51 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
);
lines.push("");
lines.push("## Docs");
+1 -1
View File
@@ -43,7 +43,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
### API (`apps/api`)
A Fastify v5 server exposing 50 tool routes (34 standard image operations + 15 AI-powered + editor) that handles:
A Fastify v5 server exposing 51 tool routes (35 standard image operations + 15 AI-powered + editor) that handles:
- File uploads, temporary workspace management, and persistent file storage
- User file library with version chains (`user_files` table) -- each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page
- Tool execution (routes each tool request to the image engine or AI bridge)
+1 -1
View File
@@ -64,7 +64,7 @@ pnpm dev
## What You Can Do
### Image Processing (50 Tools)
### Image Processing (51 Tools)
| Category | Tools |
|----------|-------|
+2 -2
View File
@@ -4,7 +4,7 @@ layout: home
hero:
name: "SnapOtter"
text: "A Self Hosted Image Manipulator"
tagline: 50 tools. Local AI. No cloud. Your images never leave your home.
tagline: 51 tools. Local AI. No cloud. Your images never leave your home.
actions:
- theme: brand
text: Get started
@@ -14,7 +14,7 @@ hero:
link: /api/rest
features:
- title: 50 Image Tools
- title: 51 Image Tools
details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more.
- title: Local AI
details: 15 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR), fix fake transparency. All on your hardware, no internet required.
+3 -3
View File
@@ -8,7 +8,7 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" });
export const metadata: Metadata = {
title: "SnapOtter | Self-Hosted Image Processing",
description:
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.",
"51 image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.",
metadataBase: new URL("https://snapotter.com"),
icons: {
icon: [
@@ -24,7 +24,7 @@ export const metadata: Metadata = {
openGraph: {
title: "SnapOtter | Self-Hosted Image Processing",
description:
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
"51 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
url: "https://snapotter.com",
siteName: "SnapOtter",
type: "website",
@@ -41,7 +41,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "SnapOtter | Self-Hosted Image Processing",
description:
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
"51 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
images: ["/og-image.svg"],
},
};
+8 -1
View File
@@ -24,6 +24,7 @@ import {
Grid3x3,
ImagePlus,
Info,
Laugh,
Layers,
LayoutGrid,
Maximize2,
@@ -275,6 +276,12 @@ const tools: { name: string; description: string; category: string; icon: Lucide
category: "watermark",
icon: Layers,
},
{
name: "Meme Generator",
description: "Create memes with templates and custom text",
category: "watermark",
icon: Laugh,
},
// Utilities
{
name: "Image Info",
@@ -408,7 +415,7 @@ export function BentoGrid() {
<div className="mx-auto max-w-6xl">
<FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
50 tools. Zero cloud dependency.
51 tools. Zero cloud dependency.
</h2>
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
Search to find exactly what you need. Every tool runs 100% locally.
+1 -1
View File
@@ -119,7 +119,7 @@ export function Hero() {
</h1>
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.1s_both] text-lg text-muted md:text-xl">
The open-source, self-hosted image processing platform.
Every image tool you need.
</p>
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.2s_both] text-xl font-medium md:text-2xl">
+1 -1
View File
@@ -7,7 +7,7 @@ const freePlan = {
price: "Free",
subtitle: "For everyone. Forever.",
features: [
"All 48 image processing tools",
"All 51 image processing tools",
"Unlimited usage, no hidden caps",
"Full REST API with OpenAPI docs",
"Pipeline automation",
+13 -1
View File
@@ -11,12 +11,24 @@ const phrases = [
"No limits. No hidden caps.",
"Works fully offline.",
"Unlimited batch processing.",
"48 image tools.",
"51 image tools.",
"15 AI models. Your hardware.",
"Lightning fast. Built on Sharp.",
"Air-gapped ready.",
"One Docker container.",
"Open source. Always.",
"Every pixel stays on your hardware.",
"Your server. Your rules.",
"Deploys in a single docker pull.",
"The self-hosted alternative.",
"Resize, compress, convert. Locally.",
"Free image editor. No watermarks.",
"Bulk image processing. Unlimited.",
"Remove backgrounds offline.",
"Self-hosted Canva alternative.",
"No API keys needed.",
"GDPR compliant by design.",
"Works without internet.",
];
export function TypingCursor() {
@@ -2460,7 +2460,7 @@ function AboutSection() {
</div>
</div>
<p className="text-sm text-muted-foreground">
A self-hosted, privacy-first image processing suite with 50 tools. Resize, compress,
A self-hosted, privacy-first image processing suite with 51 tools. Resize, compress,
convert, watermark, and automate your image workflows without sending data to the cloud.
</p>
<div className="flex items-center gap-4 text-sm">
+64 -6
View File
@@ -1,6 +1,64 @@
import { type FormEvent, useState } from "react";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { setToken } from "@/lib/api";
const phrases = [
"No signups. No accounts.",
"No uploads to the cloud. Ever.",
"100% local processing.",
"Free. Forever.",
"No limits. No hidden caps.",
"Works fully offline.",
"Unlimited batch processing.",
"51 image tools.",
"15 AI models. Your hardware.",
"Lightning fast. Built on Sharp.",
"Air-gapped ready.",
"One Docker container.",
"Open source. Always.",
"Every pixel stays on your hardware.",
"Your server. Your rules.",
"Deploys in a single docker pull.",
"The self-hosted alternative.",
"Resize, compress, convert. Locally.",
"Free image editor. No watermarks.",
"Bulk image processing. Unlimited.",
"Remove backgrounds offline.",
"Self-hosted Canva alternative.",
"No API keys needed.",
"GDPR compliant by design.",
"Works without internet.",
];
function RotatingPhrase() {
const [index, setIndex] = useState(0);
const [visible, setVisible] = useState(true);
const advance = useCallback(() => {
setVisible(false);
setTimeout(() => {
setIndex((i) => (i + 1) % phrases.length);
setVisible(true);
}, 300);
}, []);
useEffect(() => {
const timer = setInterval(advance, 3000);
return () => clearInterval(timer);
}, [advance]);
return (
<span
className="inline-block transition-all duration-300 text-white/90"
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(-8px)",
}}
>
{phrases[index]}
</span>
);
}
export function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -94,11 +152,11 @@ export function LoginPage() {
</div>
</div>
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
<div className="max-w-lg space-y-6 text-center">
<h2 className="text-3xl font-bold">Your one-stop-shop for all your image needs.</h2>
<p className="text-lg text-white/80">
A privacy-first image suite that lets you resize, compress, convert, and process images
with 33+ powerful tools.
<div className="max-w-lg space-y-4 text-center">
<h2 className="text-4xl font-extrabold tracking-tight">Your images. Stay yours.</h2>
<p className="text-lg text-white/70">Every image tool you need.</p>
<p className="text-xl font-medium h-8">
<RotatingPhrase />
</p>
</div>
</div>
+1 -1
View File
@@ -225,7 +225,7 @@ export const en = {
regenerateKey: "Regenerate",
copyKey: "Copy Key",
aboutDescription:
"SnapOtter is a self-hosted, privacy-first image processing suite with 50 tools.",
"SnapOtter is a self-hosted, privacy-first image processing suite with 51 tools.",
aboutLinks: "Links",
github: "GitHub",
documentation: "Documentation",
+2 -2
View File
@@ -13,7 +13,7 @@ test.describe("Docs Homepage", () => {
await expect(page.getByText("SnapOtter").first()).toBeVisible();
await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible();
await expect(
page.getByText("50 tools. Local AI. No cloud. Your images never leave your home."),
page.getByText("51 tools. Local AI. No cloud. Your images never leave your home."),
).toBeVisible();
});
@@ -29,7 +29,7 @@ test.describe("Docs Homepage", () => {
test("features section renders all 6 feature cards", async ({ page }) => {
const features = [
"48 Image Tools",
"51 Image Tools",
"Local AI",
"Pipelines",
"REST API",
+3 -5
View File
@@ -23,9 +23,7 @@ test.describe("Landing Homepage", () => {
test("hero section renders headline and subheadline", async ({ page }) => {
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
await expect(
page.getByText("The open-source, self-hosted image processing platform."),
).toBeVisible();
await expect(page.getByText("Every image tool you need.")).toBeVisible();
});
test("hero CTA links to GitHub", async ({ page }) => {
@@ -59,9 +57,9 @@ test.describe("Landing Homepage", () => {
});
test("bento grid renders with search and tool count", async ({ page }) => {
await expect(page.getByText("50 tools. Zero cloud dependency.")).toBeVisible();
await expect(page.getByText("51 tools. Zero cloud dependency.")).toBeVisible();
await expect(page.getByPlaceholder("Search tools...")).toBeVisible();
await expect(page.getByText(/Showing 50 of 50 tools/)).toBeVisible();
await expect(page.getByText(/Showing 51 of 51 tools/)).toBeVisible();
});
test("enterprise section renders feature cards", async ({ page }) => {
+6 -6
View File
@@ -9,28 +9,28 @@ test.describe("BentoGrid Interactions", () => {
const input = page.getByPlaceholder("Search tools...");
await input.fill("resize");
await expect(page.getByText("Resize", { exact: true }).first()).toBeVisible();
await expect(page.getByText(/Showing \d+ of 50 tools/)).toBeVisible();
await expect(page.getByText(/Showing \d+ of 51 tools/)).toBeVisible();
});
test("category pill filters tools", async ({ page }) => {
await page.getByText(/AI Tools/).click();
await expect(page.getByText(/Showing 15 of 50 tools/)).toBeVisible();
await expect(page.getByText(/Showing 15 of 51 tools/)).toBeVisible();
await expect(page.getByText("Remove Background")).toBeVisible();
});
test("clicking All resets category filter", async ({ page }) => {
await page.getByText(/AI Tools/).click();
await expect(page.getByText(/Showing 15 of 50 tools/)).toBeVisible();
await expect(page.getByText(/Showing 15 of 51 tools/)).toBeVisible();
await page.getByText(/All \(50\)/).click();
await expect(page.getByText(/Showing 50 of 50 tools/)).toBeVisible();
await page.getByText(/All \(51\)/).click();
await expect(page.getByText(/Showing 51 of 51 tools/)).toBeVisible();
});
test("search with no results shows empty state", async ({ page }) => {
const input = page.getByPlaceholder("Search tools...");
await input.fill("xyznonexistent");
await expect(page.getByText("No tools found. Try a different search.")).toBeVisible();
await expect(page.getByText(/Showing 0 of 50 tools/)).toBeVisible();
await expect(page.getByText(/Showing 0 of 51 tools/)).toBeVisible();
});
test("combined search and category filter works", async ({ page }) => {
+1 -1
View File
@@ -16,7 +16,7 @@ test.describe("Login Page", () => {
await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
// Right side: marketing text (hidden on mobile, visible on lg+)
await expect(page.getByText("Your one-stop-shop")).toBeVisible();
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
});
test("username and password inputs start empty", async ({ page }) => {
+2 -2
View File
@@ -70,7 +70,7 @@ test.describe("Responsive - Desktop (1280x720)", () => {
await page.goto("/login");
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
await expect(page.getByText("Your one-stop-shop")).toBeVisible();
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
await context.close();
});
@@ -405,7 +405,7 @@ test.describe("Responsive - Mobile (375x667)", () => {
await expect(page.getByLabel("Username")).toBeVisible();
// Marketing panel is hidden on mobile (lg:flex means only visible at lg+)
await expect(page.getByText("Your one-stop-shop")).not.toBeVisible();
await expect(page.getByText("Your images. Stay yours.")).not.toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+1 -1
View File
@@ -53,7 +53,7 @@ test.describe("Visual Mobile (375x667)", () => {
// Verify stacked layout: marketing panel should be hidden on mobile
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
await expect(page.getByText("Your one-stop-shop")).not.toBeVisible();
await expect(page.getByText("Your images. Stay yours.")).not.toBeVisible();
await takeThemedScreenshots(page, "login-empty");
});
+1 -1
View File
@@ -9,7 +9,7 @@ test.describe("Smoke tests", () => {
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
// Right panel marketing text
await expect(page.getByText("Your one-stop-shop")).toBeVisible();
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
});
test("can log in with admin credentials", async ({ page }) => {
+1 -1
View File
@@ -47,7 +47,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
lines.push(`# ${spec.info.title}`);
lines.push("");
lines.push(
"> Self-hosted image processing API with 50 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
"> Self-hosted image processing API with 51 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
);
lines.push("");
lines.push("## Docs");
+9 -9
View File
@@ -25,7 +25,7 @@ function getCountText(): string {
describe("BentoGrid", () => {
it("renders the section heading", () => {
render(<BentoGrid />);
expect(screen.getByText("50 tools. Zero cloud dependency.")).toBeDefined();
expect(screen.getByText("51 tools. Zero cloud dependency.")).toBeDefined();
});
it("renders the search input", () => {
@@ -36,12 +36,12 @@ describe("BentoGrid", () => {
it("shows all tools by default", () => {
render(<BentoGrid />);
const text = getCountText();
expect(text).toMatch(/Showing 50 of 50 tools/);
expect(text).toMatch(/Showing 51 of 51 tools/);
});
it("renders category filter pills including All", () => {
render(<BentoGrid />);
expect(screen.getByText((_, el) => el?.textContent === "All (50)")).toBeDefined();
expect(screen.getByText((_, el) => el?.textContent === "All (51)")).toBeDefined();
expect(screen.getByText(/Essentials/)).toBeDefined();
expect(screen.getByText(/AI Tools/)).toBeDefined();
expect(screen.getByText(/Optimization/)).toBeDefined();
@@ -53,7 +53,7 @@ describe("BentoGrid", () => {
fireEvent.change(input, { target: { value: "resize" } });
expect(screen.getByText("Resize")).toBeDefined();
const text = getCountText();
expect(text).toMatch(/Showing \d+ of 50 tools/);
expect(text).toMatch(/Showing \d+ of 51 tools/);
expect(screen.queryByText("OCR / Text Extraction")).toBeNull();
});
@@ -62,7 +62,7 @@ describe("BentoGrid", () => {
const aiButton = screen.getByText(/AI Tools/);
fireEvent.click(aiButton);
const text = getCountText();
expect(text).toMatch(/Showing 15 of 50 tools/);
expect(text).toMatch(/Showing 15 of 51 tools/);
expect(screen.getByText("Remove Background")).toBeDefined();
expect(screen.queryByText("Resize")).toBeNull();
});
@@ -73,7 +73,7 @@ describe("BentoGrid", () => {
fireEvent.change(input, { target: { value: "xyznonexistent" } });
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
const text = getCountText();
expect(text).toMatch(/Showing 0 of 50 tools/);
expect(text).toMatch(/Showing 0 of 51 tools/);
});
it("combines search and category filter", () => {
@@ -89,9 +89,9 @@ describe("BentoGrid", () => {
it("clicking All resets category filter", () => {
render(<BentoGrid />);
fireEvent.click(screen.getByText(/AI Tools/));
expect(getCountText()).toMatch(/Showing 15 of 50 tools/);
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (50)"));
expect(getCountText()).toMatch(/Showing 50 of 50 tools/);
expect(getCountText()).toMatch(/Showing 15 of 51 tools/);
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (51)"));
expect(getCountText()).toMatch(/Showing 51 of 51 tools/);
});
it("renders each tool with name and description", () => {
+2 -4
View File
@@ -41,9 +41,7 @@ describe("Hero", () => {
it("renders the subheadline", () => {
render(<Hero />);
expect(
screen.getByText("The open-source, self-hosted image processing platform."),
).toBeDefined();
expect(screen.getByText("Every image tool you need.")).toBeDefined();
});
it("renders the CTA button linking to GitHub", () => {
@@ -159,7 +157,7 @@ describe("Pricing", () => {
it("renders free plan features", () => {
render(<Pricing />);
expect(screen.getByText("All 48 image processing tools")).toBeDefined();
expect(screen.getByText("All 51 image processing tools")).toBeDefined();
expect(screen.getByText("Unlimited usage, no hidden caps")).toBeDefined();
expect(screen.getByText("15 local AI models included")).toBeDefined();
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();