mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
|
|||||||
lines.push(`# ${spec.info.title}`);
|
lines.push(`# ${spec.info.title}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
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("");
|
||||||
lines.push("## Docs");
|
lines.push("## Docs");
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
|
|||||||
|
|
||||||
### API (`apps/api`)
|
### 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
|
- 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
|
- 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)
|
- Tool execution (routes each tool request to the image engine or AI bridge)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ pnpm dev
|
|||||||
|
|
||||||
## What You Can Do
|
## What You Can Do
|
||||||
|
|
||||||
### Image Processing (50 Tools)
|
### Image Processing (51 Tools)
|
||||||
|
|
||||||
| Category | Tools |
|
| Category | Tools |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ layout: home
|
|||||||
hero:
|
hero:
|
||||||
name: "SnapOtter"
|
name: "SnapOtter"
|
||||||
text: "A Self Hosted Image Manipulator"
|
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:
|
actions:
|
||||||
- theme: brand
|
- theme: brand
|
||||||
text: Get started
|
text: Get started
|
||||||
@@ -14,7 +14,7 @@ hero:
|
|||||||
link: /api/rest
|
link: /api/rest
|
||||||
|
|
||||||
features:
|
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.
|
details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more.
|
||||||
- title: Local AI
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" });
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "SnapOtter | Self-Hosted Image Processing",
|
title: "SnapOtter | Self-Hosted Image Processing",
|
||||||
description:
|
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"),
|
metadataBase: new URL("https://snapotter.com"),
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
@@ -24,7 +24,7 @@ export const metadata: Metadata = {
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: "SnapOtter | Self-Hosted Image Processing",
|
title: "SnapOtter | Self-Hosted Image Processing",
|
||||||
description:
|
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",
|
url: "https://snapotter.com",
|
||||||
siteName: "SnapOtter",
|
siteName: "SnapOtter",
|
||||||
type: "website",
|
type: "website",
|
||||||
@@ -41,7 +41,7 @@ export const metadata: Metadata = {
|
|||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "SnapOtter | Self-Hosted Image Processing",
|
title: "SnapOtter | Self-Hosted Image Processing",
|
||||||
description:
|
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"],
|
images: ["/og-image.svg"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Grid3x3,
|
Grid3x3,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Info,
|
Info,
|
||||||
|
Laugh,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
@@ -275,6 +276,12 @@ const tools: { name: string; description: string; category: string; icon: Lucide
|
|||||||
category: "watermark",
|
category: "watermark",
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Meme Generator",
|
||||||
|
description: "Create memes with templates and custom text",
|
||||||
|
category: "watermark",
|
||||||
|
icon: Laugh,
|
||||||
|
},
|
||||||
// Utilities
|
// Utilities
|
||||||
{
|
{
|
||||||
name: "Image Info",
|
name: "Image Info",
|
||||||
@@ -408,7 +415,7 @@ export function BentoGrid() {
|
|||||||
<div className="mx-auto max-w-6xl">
|
<div className="mx-auto max-w-6xl">
|
||||||
<FadeIn>
|
<FadeIn>
|
||||||
<h2 className="font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
|
<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>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
|
<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.
|
Search to find exactly what you need. Every tool runs 100% locally.
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function Hero() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.1s_both] text-lg text-muted md:text-xl">
|
<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>
|
||||||
|
|
||||||
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.2s_both] text-xl font-medium md:text-2xl">
|
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.2s_both] text-xl font-medium md:text-2xl">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const freePlan = {
|
|||||||
price: "Free",
|
price: "Free",
|
||||||
subtitle: "For everyone. Forever.",
|
subtitle: "For everyone. Forever.",
|
||||||
features: [
|
features: [
|
||||||
"All 48 image processing tools",
|
"All 51 image processing tools",
|
||||||
"Unlimited usage, no hidden caps",
|
"Unlimited usage, no hidden caps",
|
||||||
"Full REST API with OpenAPI docs",
|
"Full REST API with OpenAPI docs",
|
||||||
"Pipeline automation",
|
"Pipeline automation",
|
||||||
|
|||||||
@@ -11,12 +11,24 @@ const phrases = [
|
|||||||
"No limits. No hidden caps.",
|
"No limits. No hidden caps.",
|
||||||
"Works fully offline.",
|
"Works fully offline.",
|
||||||
"Unlimited batch processing.",
|
"Unlimited batch processing.",
|
||||||
"48 image tools.",
|
"51 image tools.",
|
||||||
"15 AI models. Your hardware.",
|
"15 AI models. Your hardware.",
|
||||||
"Lightning fast. Built on Sharp.",
|
"Lightning fast. Built on Sharp.",
|
||||||
"Air-gapped ready.",
|
"Air-gapped ready.",
|
||||||
"One Docker container.",
|
"One Docker container.",
|
||||||
"Open source. Always.",
|
"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() {
|
export function TypingCursor() {
|
||||||
|
|||||||
@@ -2460,7 +2460,7 @@ function AboutSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
convert, watermark, and automate your image workflows without sending data to the cloud.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
|||||||
@@ -1,6 +1,64 @@
|
|||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
import { setToken } from "@/lib/api";
|
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() {
|
export function LoginPage() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -94,11 +152,11 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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="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">
|
<div className="max-w-lg space-y-4 text-center">
|
||||||
<h2 className="text-3xl font-bold">Your one-stop-shop for all your image needs.</h2>
|
<h2 className="text-4xl font-extrabold tracking-tight">Your images. Stay yours.</h2>
|
||||||
<p className="text-lg text-white/80">
|
<p className="text-lg text-white/70">Every image tool you need.</p>
|
||||||
A privacy-first image suite that lets you resize, compress, convert, and process images
|
<p className="text-xl font-medium h-8">
|
||||||
with 33+ powerful tools.
|
<RotatingPhrase />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export const en = {
|
|||||||
regenerateKey: "Regenerate",
|
regenerateKey: "Regenerate",
|
||||||
copyKey: "Copy Key",
|
copyKey: "Copy Key",
|
||||||
aboutDescription:
|
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",
|
aboutLinks: "Links",
|
||||||
github: "GitHub",
|
github: "GitHub",
|
||||||
documentation: "Documentation",
|
documentation: "Documentation",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ test.describe("Docs Homepage", () => {
|
|||||||
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
||||||
await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible();
|
await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible();
|
||||||
await expect(
|
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();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ test.describe("Docs Homepage", () => {
|
|||||||
|
|
||||||
test("features section renders all 6 feature cards", async ({ page }) => {
|
test("features section renders all 6 feature cards", async ({ page }) => {
|
||||||
const features = [
|
const features = [
|
||||||
"48 Image Tools",
|
"51 Image Tools",
|
||||||
"Local AI",
|
"Local AI",
|
||||||
"Pipelines",
|
"Pipelines",
|
||||||
"REST API",
|
"REST API",
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ test.describe("Landing Homepage", () => {
|
|||||||
|
|
||||||
test("hero section renders headline and subheadline", async ({ page }) => {
|
test("hero section renders headline and subheadline", async ({ page }) => {
|
||||||
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
|
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
|
||||||
await expect(
|
await expect(page.getByText("Every image tool you need.")).toBeVisible();
|
||||||
page.getByText("The open-source, self-hosted image processing platform."),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hero CTA links to GitHub", async ({ page }) => {
|
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 }) => {
|
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.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 }) => {
|
test("enterprise section renders feature cards", async ({ page }) => {
|
||||||
|
|||||||
@@ -9,28 +9,28 @@ test.describe("BentoGrid Interactions", () => {
|
|||||||
const input = page.getByPlaceholder("Search tools...");
|
const input = page.getByPlaceholder("Search tools...");
|
||||||
await input.fill("resize");
|
await input.fill("resize");
|
||||||
await expect(page.getByText("Resize", { exact: true }).first()).toBeVisible();
|
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 }) => {
|
test("category pill filters tools", async ({ page }) => {
|
||||||
await page.getByText(/AI Tools/).click();
|
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();
|
await expect(page.getByText("Remove Background")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking All resets category filter", async ({ page }) => {
|
test("clicking All resets category filter", async ({ page }) => {
|
||||||
await page.getByText(/AI Tools/).click();
|
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 page.getByText(/All \(51\)/).click();
|
||||||
await expect(page.getByText(/Showing 50 of 50 tools/)).toBeVisible();
|
await expect(page.getByText(/Showing 51 of 51 tools/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("search with no results shows empty state", async ({ page }) => {
|
test("search with no results shows empty state", async ({ page }) => {
|
||||||
const input = page.getByPlaceholder("Search tools...");
|
const input = page.getByPlaceholder("Search tools...");
|
||||||
await input.fill("xyznonexistent");
|
await input.fill("xyznonexistent");
|
||||||
await expect(page.getByText("No tools found. Try a different search.")).toBeVisible();
|
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 }) => {
|
test("combined search and category filter works", async ({ page }) => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ test.describe("Login Page", () => {
|
|||||||
await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
|
await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
|
||||||
|
|
||||||
// Right side: marketing text (hidden on mobile, visible on lg+)
|
// 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 }) => {
|
test("username and password inputs start empty", async ({ page }) => {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ test.describe("Responsive - Desktop (1280x720)", () => {
|
|||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
|
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();
|
await context.close();
|
||||||
});
|
});
|
||||||
@@ -405,7 +405,7 @@ test.describe("Responsive - Mobile (375x667)", () => {
|
|||||||
await expect(page.getByLabel("Username")).toBeVisible();
|
await expect(page.getByLabel("Username")).toBeVisible();
|
||||||
|
|
||||||
// Marketing panel is hidden on mobile (lg:flex means only visible at lg+)
|
// 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 scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ test.describe("Visual Mobile (375x667)", () => {
|
|||||||
|
|
||||||
// Verify stacked layout: marketing panel should be hidden on mobile
|
// Verify stacked layout: marketing panel should be hidden on mobile
|
||||||
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
|
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");
|
await takeThemedScreenshots(page, "login-empty");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ test.describe("Smoke tests", () => {
|
|||||||
await expect(page.getByLabel("Password")).toBeVisible();
|
await expect(page.getByLabel("Password")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
|
await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
|
||||||
// Right panel marketing text
|
// 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 }) => {
|
test("can log in with admin credentials", async ({ page }) => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
|
|||||||
lines.push(`# ${spec.info.title}`);
|
lines.push(`# ${spec.info.title}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
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("");
|
||||||
lines.push("## Docs");
|
lines.push("## Docs");
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function getCountText(): string {
|
|||||||
describe("BentoGrid", () => {
|
describe("BentoGrid", () => {
|
||||||
it("renders the section heading", () => {
|
it("renders the section heading", () => {
|
||||||
render(<BentoGrid />);
|
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", () => {
|
it("renders the search input", () => {
|
||||||
@@ -36,12 +36,12 @@ describe("BentoGrid", () => {
|
|||||||
it("shows all tools by default", () => {
|
it("shows all tools by default", () => {
|
||||||
render(<BentoGrid />);
|
render(<BentoGrid />);
|
||||||
const text = getCountText();
|
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", () => {
|
it("renders category filter pills including All", () => {
|
||||||
render(<BentoGrid />);
|
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(/Essentials/)).toBeDefined();
|
||||||
expect(screen.getByText(/AI Tools/)).toBeDefined();
|
expect(screen.getByText(/AI Tools/)).toBeDefined();
|
||||||
expect(screen.getByText(/Optimization/)).toBeDefined();
|
expect(screen.getByText(/Optimization/)).toBeDefined();
|
||||||
@@ -53,7 +53,7 @@ describe("BentoGrid", () => {
|
|||||||
fireEvent.change(input, { target: { value: "resize" } });
|
fireEvent.change(input, { target: { value: "resize" } });
|
||||||
expect(screen.getByText("Resize")).toBeDefined();
|
expect(screen.getByText("Resize")).toBeDefined();
|
||||||
const text = getCountText();
|
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();
|
expect(screen.queryByText("OCR / Text Extraction")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ describe("BentoGrid", () => {
|
|||||||
const aiButton = screen.getByText(/AI Tools/);
|
const aiButton = screen.getByText(/AI Tools/);
|
||||||
fireEvent.click(aiButton);
|
fireEvent.click(aiButton);
|
||||||
const text = getCountText();
|
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.getByText("Remove Background")).toBeDefined();
|
||||||
expect(screen.queryByText("Resize")).toBeNull();
|
expect(screen.queryByText("Resize")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ describe("BentoGrid", () => {
|
|||||||
fireEvent.change(input, { target: { value: "xyznonexistent" } });
|
fireEvent.change(input, { target: { value: "xyznonexistent" } });
|
||||||
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
|
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
|
||||||
const text = getCountText();
|
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", () => {
|
it("combines search and category filter", () => {
|
||||||
@@ -89,9 +89,9 @@ describe("BentoGrid", () => {
|
|||||||
it("clicking All resets category filter", () => {
|
it("clicking All resets category filter", () => {
|
||||||
render(<BentoGrid />);
|
render(<BentoGrid />);
|
||||||
fireEvent.click(screen.getByText(/AI Tools/));
|
fireEvent.click(screen.getByText(/AI Tools/));
|
||||||
expect(getCountText()).toMatch(/Showing 15 of 50 tools/);
|
expect(getCountText()).toMatch(/Showing 15 of 51 tools/);
|
||||||
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (50)"));
|
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (51)"));
|
||||||
expect(getCountText()).toMatch(/Showing 50 of 50 tools/);
|
expect(getCountText()).toMatch(/Showing 51 of 51 tools/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders each tool with name and description", () => {
|
it("renders each tool with name and description", () => {
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ describe("Hero", () => {
|
|||||||
|
|
||||||
it("renders the subheadline", () => {
|
it("renders the subheadline", () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
expect(
|
expect(screen.getByText("Every image tool you need.")).toBeDefined();
|
||||||
screen.getByText("The open-source, self-hosted image processing platform."),
|
|
||||||
).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the CTA button linking to GitHub", () => {
|
it("renders the CTA button linking to GitHub", () => {
|
||||||
@@ -159,7 +157,7 @@ describe("Pricing", () => {
|
|||||||
|
|
||||||
it("renders free plan features", () => {
|
it("renders free plan features", () => {
|
||||||
render(<Pricing />);
|
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("Unlimited usage, no hidden caps")).toBeDefined();
|
||||||
expect(screen.getByText("15 local AI models included")).toBeDefined();
|
expect(screen.getByText("15 local AI models included")).toBeDefined();
|
||||||
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();
|
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();
|
||||||
|
|||||||
Reference in New Issue
Block a user