feat: add 52 per-tool SEO landing pages and public changelog (#145)

* feat: add 52 per-tool SEO landing pages and public changelog

- Create individual landing pages for all 52 tools at /tools/{slug}
  with search-intent title tags, unique FAQs (156 Q&As), per-tool
  features, and rich schema markup (BreadcrumbList, WebApplication,
  HowTo, FAQPage)
- Replace static sitemap.xml with dynamic generation including all
  tool URLs
- Refactor bento grid to import from @snapotter/shared (eliminates
  330 lines of duplicated tool data) and link cards to tool pages
- Add @snapotter/shared as workspace dependency to landing site
- Add public changelog page to docs site with curated release notes
  from v1.8 through v1.17
- Update docs nav and sidebar with changelog link

* feat: enhance SEO and performance with updated metadata, robots.txt, and llms.txt
This commit is contained in:
SnapOtter
2026-05-18 16:39:54 +08:00
committed by GitHub
parent 0dadca3b99
commit 9941a1db03
22 changed files with 2580 additions and 439 deletions
+18
View File
@@ -134,7 +134,25 @@ interface LockData {
startedAt: string; startedAt: string;
} }
const LOCK_STALE_MS = 45 * 60 * 1000;
export function acquireInstallLock(bundleId: string): boolean { export function acquireInstallLock(bundleId: string): boolean {
// If a lock exists, check its age. OOM-killed processes or crashes
// may leave a stale lock file behind; treat anything older than 45
// minutes as abandoned.
if (existsSync(LOCK_PATH)) {
try {
const age = Date.now() - statSync(LOCK_PATH).mtimeMs;
if (age > LOCK_STALE_MS) {
unlinkSync(LOCK_PATH);
console.warn(
`[feature-status] Removed stale install lock (age: ${Math.round(age / 1000)}s)`,
);
}
} catch {
// stat/unlink failed -- fall through to O_EXCL which will fail too
}
}
try { try {
const fd = openSync(LOCK_PATH, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL); const fd = openSync(LOCK_PATH, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
const lock: LockData = { const lock: LockData = {
+5
View File
@@ -219,6 +219,11 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
// Take over the response from Fastify for SSE streaming // Take over the response from Fastify for SSE streaming
reply.hijack(); reply.hijack();
// Disable socket timeout -- feature installs can take 30+ minutes
// for large model downloads. Without this, Node's requestTimeout
// kills the SSE connection mid-install.
request.raw.socket?.setTimeout?.(0);
// Send SSE headers via the raw Node response // Send SSE headers via the raw Node response
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
+20 -2
View File
@@ -26,12 +26,25 @@ export default defineConfig({
["meta", { property: "og:image:width", content: "1280" }], ["meta", { property: "og:image:width", content: "1280" }],
["meta", { property: "og:image:height", content: "640" }], ["meta", { property: "og:image:height", content: "640" }],
["meta", { property: "og:image:alt", content: "SnapOtter - Self-Hosted Image Processing" }], ["meta", { property: "og:image:alt", content: "SnapOtter - Self-Hosted Image Processing" }],
["meta", { property: "og:url", content: "https://docs.snapotter.com" }],
["meta", { property: "og:locale", content: "en_US" }], ["meta", { property: "og:locale", content: "en_US" }],
["meta", { name: "twitter:card", content: "summary_large_image" }], ["meta", { name: "twitter:card", content: "summary_large_image" }],
["meta", { name: "twitter:site", content: "@SnapOtterHQ" }],
["meta", { name: "twitter:image", content: "https://docs.snapotter.com/og-image.png" }], ["meta", { name: "twitter:image", content: "https://docs.snapotter.com/og-image.png" }],
], ],
transformHead({ pageData }) {
const head: Array<[string, Record<string, string>]> = [];
const canonicalUrl = `https://docs.snapotter.com/${pageData.relativePath.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, "")}`;
head.push(["meta", { property: "og:url", content: canonicalUrl }]);
head.push(["meta", { property: "og:title", content: pageData.title }]);
if (pageData.description) {
head.push(["meta", { property: "og:description", content: pageData.description }]);
head.push(["meta", { name: "twitter:description", content: pageData.description }]);
}
head.push(["meta", { name: "twitter:title", content: pageData.title }]);
return head;
},
vite: { vite: {
plugins: [ plugins: [
llmstxt({ llmstxt({
@@ -78,9 +91,10 @@ export default defineConfig({
{ text: "Home", link: "/" }, { text: "Home", link: "/" },
{ text: "Guide", link: "/guide/getting-started" }, { text: "Guide", link: "/guide/getting-started" },
{ text: "API Reference", link: "/api/rest" }, { text: "API Reference", link: "/api/rest" },
{ text: "Changelog", link: "/changelog" },
{ {
text: `v${pkg.version}`, text: `v${pkg.version}`,
link: "https://github.com/snapotter-hq/snapotter/releases", link: "/changelog",
}, },
], ],
@@ -110,6 +124,10 @@ export default defineConfig({
{ text: "AI engine", link: "/api/ai" }, { text: "AI engine", link: "/api/ai" },
], ],
}, },
{
text: "Project",
items: [{ text: "Changelog", link: "/changelog" }],
},
], ],
search: { search: {
+111
View File
@@ -0,0 +1,111 @@
---
description: Release notes and version history for SnapOtter. See what's new, improved, and fixed in each release.
---
# Changelog
## v1.17.0
Five new tools, a full image editor, SSO login, 20 languages. Probably should have been three separate releases, but here we are.
### New features
- **Image editor** -- Layers, brushes, shapes, adjustments, filters, curves, keyboard shortcuts. Runs in your browser, processes on your hardware.
- **OIDC / SSO authentication** -- Login with Google, GitHub, Okta, or any OpenID Connect provider. Set a few env vars and your team uses their existing accounts.
- **Meme generator** -- 100 built-in templates with text rendering via opentype.js. Or upload your own image.
- **Beautify** -- Drop a screenshot in, get a polished image out. Device frames (macOS, Windows, browser), shadows, gradients, social media presets.
- **Color blindness simulation** -- Preview how images look with protanopia, deuteranopia, tritanopia, and other color vision deficiencies.
- **PNG transparency fixer** -- Detects fake-transparent PNGs and fixes them with BiRefNet HR-matting. Optional watermark removal via LaMa inpainting.
- **AI canvas expand** -- Extend image boundaries with AI fill. Three quality tiers (fast, balanced, quality) depending on how much GPU time you want to trade.
- **20 languages** -- Arabic, Chinese (Simplified/Traditional), Czech, Dutch, French, German, Hindi, Indonesian, Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish, Thai, Turkish, Ukrainian, Vietnamese. RTL works for Arabic.
- **URL import** -- Paste URLs into the dropzone or bulk-import from a list. Server-side fetch with SSRF protection.
- **Multi-file eraser** -- Draw erase masks across multiple images, process them all with one click. Strokes persist per-image.
- **Pipeline import/export** -- Save tool chains as JSON, share them with others.
- **17 new camera RAW formats** via exiftool, plus QOI, JP2, EPS, DDS, CUR, DPX, FITS, PPM/PGM/PBM, SVGZ, and APNG input. New output codecs for BMP, ICO, JP2, QOI. AVIF, TIFF, GIF, JXL, and PSD export recovered from a previously lost branch.
### Improvements
- **Image enhancement** -- Replaced the old pipeline with CLAHE + normalise + gamma. New Deep Enhance toggle uses the AI model for more aggressive results.
- **Restore photo** -- Scratch detection rewritten with 8-angle Otsu filtering. LaMa inpainting now runs at native resolution.
- **Exotic formats everywhere** -- OCR, image-to-PDF, favicon generator, composition, stitch, and vectorize all decode HEIC, RAW, PSD now.
- **Compress** -- Target-size tolerance tightened from 5% to 1%. Target size is the default mode. Added stepper buttons and KB/MB unit selector.
- **Sentry cleanup** -- 644 non-actionable events filtered. Real errors now handled properly.
- **GPU detection** -- Better diagnostics for containers where CUDA is present but nvidia-smi is not.
- **Auth-disabled mode** -- Anonymous user is seeded in the DB with admin role. API keys, pipelines, and user files no longer break on FK constraints.
- **2,705+ new tests** across unit, integration, and E2E.
### Bug fixes
- Upscale on CPU no longer times out on NAS boxes and low-power hardware.
- QR code logo no longer makes the preview vanish permanently.
- Crop overflow fixed for tall portrait images.
- TIFF alpha files correctly force PNG output instead of producing corruption.
- HDR/EXR decode converts to 8-bit before CLAHE, fixing decode failures.
- Face landmarks input buffers converted to PNG before the Python sidecar, fixing crashes.
- Find duplicates handles mixed-format batches and network errors.
- Beautify preview updates in real time.
- Progress bars for stitch and vectorize.
- SVGZ handled by SVG-to-raster.
- Non-ASCII filenames fixed via percent-encoded X-File-Results header.
### Upgrade
```bash
docker pull snapotter/snapotter:1.17.0
```
Or with Docker Compose:
```bash
docker compose pull && docker compose up -d
```
[Full diff on GitHub](https://github.com/snapotter-hq/SnapOtter/compare/v1.16.0...v1.17.0)
---
## v1.14.0
Unified Docker image with GPU auto-detection. One image handles both CPU and GPU workloads. Simplified compose to a single file with log rotation. Model pre-downloads now include verification and a smoke test.
---
## v1.13.0
Role-based access control (RBAC). 14 granular permissions, three built-in roles (admin, editor, user), custom role support. Permission checks on all API routes. Frontend tabs filtered by user permissions.
---
## v1.12.0
PDF to Image tool. Convert PDF pages to PNG, JPEG, WebP, or TIFF at custom DPI. Unified Docker image with GPU auto-detection.
---
## v1.11.0
Auto-generated llms.txt via vitepress-plugin-llms for AI-friendly documentation.
---
## v1.10.0
Content-aware resize (seam carving) with face protection. Resize images while preserving important content.
---
## v1.9.0
Stitch / Combine tool. Join images side by side, stacked vertically, or in a custom grid.
---
## v1.8.0
Edit Metadata tool. View and edit EXIF, IPTC, and XMP metadata with a granular strip/keep interface.
---
## Older releases
For the full commit-level changelog including patch releases, see [GitHub Releases](https://github.com/snapotter-hq/snapotter/releases).
+21
View File
@@ -1,4 +1,25 @@
User-agent: * User-agent: *
Allow: / Allow: /
User-agent: GPTBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: cohere-ai
Allow: /
Sitemap: https://docs.snapotter.com/sitemap.xml Sitemap: https://docs.snapotter.com/sitemap.xml
+7
View File
@@ -2,6 +2,13 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "export", output: "export",
transpilePackages: ["@snapotter/shared"],
webpack: (config) => {
config.resolve.extensionAlias = {
".js": [".ts", ".tsx", ".js"],
};
return config;
},
}; };
export default nextConfig; export default nextConfig;
+1
View File
@@ -12,6 +12,7 @@
"clean": "rm -rf .next" "clean": "rm -rf .next"
}, },
"dependencies": { "dependencies": {
"@snapotter/shared": "workspace:*",
"framer-motion": "^11.18.0", "framer-motion": "^11.18.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "^15.5.18", "next": "^15.5.18",
+103
View File
@@ -0,0 +1,103 @@
# SnapOtter
> Open-source, self-hostable image manipulation suite with 52 tools. Single Docker container, no external services. Dual-licensed AGPLv3 / commercial.
SnapOtter runs entirely on your own infrastructure. Images are processed locally with no data sent to external APIs. Deploy once with Docker and use it as much as you need.
## Getting Started
- [Documentation](https://docs.snapotter.com/guide/getting-started): Installation, configuration, and deployment guide
- [Docker Hub](https://hub.docker.com/r/snapotter/snapotter): Official Docker image with multi-arch support
- [GitHub](https://github.com/snapotter-hq/snapotter): Source code, issues, and contributions
## Core Tools
- [Resize](https://snapotter.com/tools/resize): Resize images by pixels, percentage, or social media presets
- [Crop](https://snapotter.com/tools/crop): Freeform, fixed aspect ratio, and shape-based cropping
- [Rotate](https://snapotter.com/tools/rotate): Rotate by any angle, flip, and auto-straighten
- [Convert](https://snapotter.com/tools/convert): Convert between JPEG, PNG, WebP, AVIF, TIFF, GIF, HEIC, RAW
- [Compress](https://snapotter.com/tools/compress): Quality-based or target file size compression
- [Optimize for Web](https://snapotter.com/tools/optimize-for-web): Smart format selection and quality tuning for web delivery
- [Strip Metadata](https://snapotter.com/tools/strip-metadata): Remove EXIF, GPS, and camera data for privacy
- [Edit Metadata](https://snapotter.com/tools/edit-metadata): View and edit EXIF, IPTC, and XMP fields
- [Bulk Rename](https://snapotter.com/tools/bulk-rename): Pattern-based batch renaming with variables
- [Image to PDF](https://snapotter.com/tools/image-to-pdf): Combine images into multi-page PDFs
- [Favicon](https://snapotter.com/tools/favicon): Generate all favicon sizes from a single image
## Color and Adjustment Tools
- [Adjust Colors](https://snapotter.com/tools/adjust-colors): Brightness, contrast, saturation, temperature, and effects
- [Sharpening](https://snapotter.com/tools/sharpening): Adaptive, unsharp mask, and high-pass sharpening
- [Replace Color](https://snapotter.com/tools/replace-color): Swap colors with tolerance control or invert
- [Color Blindness](https://snapotter.com/tools/color-blindness): Simulate 8 types of color vision deficiency
## AI-Powered Tools
- [Remove Background](https://snapotter.com/tools/remove-background): AI background removal (rembg) running 100% locally
- [Upscale](https://snapotter.com/tools/upscale): 2x and 4x AI super-resolution with RealESRGAN
- [Erase Object](https://snapotter.com/tools/erase-object): AI inpainting to remove unwanted objects (LaMa)
- [OCR](https://snapotter.com/tools/ocr): Extract text from images with PaddleOCR (80+ languages)
- [Blur Faces](https://snapotter.com/tools/blur-faces): AI face detection with blur for GDPR compliance
- [Smart Crop](https://snapotter.com/tools/smart-crop): Subject-aware and face-centered cropping
- [Image Enhancement](https://snapotter.com/tools/image-enhancement): One-click auto-fix for exposure, contrast, and color
- [Enhance Faces](https://snapotter.com/tools/enhance-faces): AI face restoration for blurry or low-res portraits
- [Colorize](https://snapotter.com/tools/colorize): Convert black-and-white photos to full color with AI
- [Noise Removal](https://snapotter.com/tools/noise-removal): AI denoising for ISO noise, JPEG artifacts, and grain
- [Red Eye Removal](https://snapotter.com/tools/red-eye-removal): Automatic red-eye detection and correction
- [Restore Photo](https://snapotter.com/tools/restore-photo): AI repair of scratches, tears, and damage
- [Passport Photo](https://snapotter.com/tools/passport-photo): Compliant passport/ID photos for 30+ countries
- [Content-Aware Resize](https://snapotter.com/tools/content-aware-resize): Seam carving that preserves subjects
- [AI Canvas Expand](https://snapotter.com/tools/ai-canvas-expand): Outpainting to extend image borders with AI
- [Transparency Fixer](https://snapotter.com/tools/transparency-fixer): Fix PNGs with fake transparent backgrounds
## Watermark and Overlay Tools
- [Text Watermark](https://snapotter.com/tools/watermark-text): Add text watermarks with tiling and opacity control
- [Image Watermark](https://snapotter.com/tools/watermark-image): Overlay logos with position and opacity control
- [Text Overlay](https://snapotter.com/tools/text-overlay): Add styled text, captions, and titles to images
- [Compose](https://snapotter.com/tools/compose): Layer and composite multiple images with blending
- [Meme Generator](https://snapotter.com/tools/meme-generator): Create memes with custom text and templates
## Analysis and Utility Tools
- [Image Info](https://snapotter.com/tools/info): View dimensions, EXIF, GPS, color space, and file details
- [Compare](https://snapotter.com/tools/compare): Side-by-side image comparison with slider overlay
- [Find Duplicates](https://snapotter.com/tools/find-duplicates): Perceptual hashing for near-duplicate detection
- [Color Palette](https://snapotter.com/tools/color-palette): Extract dominant colors as hex/RGB values
- [QR Code Generator](https://snapotter.com/tools/qr-generate): Custom QR codes with colors, logos, and patterns
- [Barcode Reader](https://snapotter.com/tools/barcode-read): Decode QR, Code 128, EAN-13, and more from images
- [Image to Base64](https://snapotter.com/tools/image-to-base64): Generate data URIs for HTML/CSS embedding
## Layout and Composition Tools
- [Collage](https://snapotter.com/tools/collage): 25+ grid templates for photo collages
- [Stitch](https://snapotter.com/tools/stitch): Join images side by side, stacked, or in grids
- [Split](https://snapotter.com/tools/split): Cut images into grid tiles for Instagram and print
- [Border](https://snapotter.com/tools/border): Add borders, rounded corners, and drop shadows
- [Beautify](https://snapotter.com/tools/beautify): Screenshot mockups with device frames and backgrounds
## Format Conversion Tools
- [SVG to Raster](https://snapotter.com/tools/svg-to-raster): Convert SVG to PNG, JPEG, WebP at custom scale/DPI
- [Vectorize](https://snapotter.com/tools/vectorize): Trace raster images to SVG vector paths
- [GIF Tools](https://snapotter.com/tools/gif-tools): Resize, optimize, reverse, and extract GIF frames
- [PDF to Image](https://snapotter.com/tools/pdf-to-image): Convert PDF pages to images at custom DPI
## Key Features
- Self-hosted: all processing on your infrastructure, no cloud dependency
- Privacy-first: images never leave your network
- Batch processing: process hundreds of files at once
- Pipelines: chain tools sequentially for automated workflows
- REST API: integrate with any system via HTTP
- 21 languages supported
- Docker: single container deployment
- Open source: AGPLv3 with commercial license available
## Links
- Website: https://snapotter.com
- Documentation: https://docs.snapotter.com
- GitHub: https://github.com/snapotter-hq/snapotter
- Docker Hub: https://hub.docker.com/r/snapotter/snapotter
- Discord: https://discord.gg/hr3s7HPUsr
+21
View File
@@ -1,5 +1,26 @@
User-agent: * User-agent: *
Allow: / Allow: /
User-agent: GPTBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: cohere-ai
Allow: /
Sitemap: https://snapotter.com/sitemap.xml Sitemap: https://snapotter.com/sitemap.xml
Sitemap: https://docs.snapotter.com/sitemap.xml Sitemap: https://docs.snapotter.com/sitemap.xml
-33
View File
@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://snapotter.com/</loc>
<lastmod>2026-05-16</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://snapotter.com/faq</loc>
<lastmod>2026-05-16</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://snapotter.com/contact</loc>
<lastmod>2026-05-16</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://snapotter.com/privacy</loc>
<lastmod>2026-05-16</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://snapotter.com/terms</loc>
<lastmod>2026-05-16</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+11
View File
@@ -5,6 +5,17 @@ export const metadata: Metadata = {
description: description:
"Book a demo, get deployment help, or discuss enterprise licensing for SnapOtter self-hosted image processing.", "Book a demo, get deployment help, or discuss enterprise licensing for SnapOtter self-hosted image processing.",
alternates: { canonical: "https://snapotter.com/contact" }, alternates: { canonical: "https://snapotter.com/contact" },
openGraph: {
title: "Contact | SnapOtter",
description:
"Book a demo, get deployment help, or discuss enterprise licensing for SnapOtter self-hosted image processing.",
url: "https://snapotter.com/contact",
},
twitter: {
title: "Contact | SnapOtter",
description:
"Book a demo, get deployment help, or discuss enterprise licensing for SnapOtter self-hosted image processing.",
},
}; };
export default function ContactLayout({ children }: { children: React.ReactNode }) { export default function ContactLayout({ children }: { children: React.ReactNode }) {
+11
View File
@@ -5,6 +5,17 @@ export const metadata: Metadata = {
description: description:
"Frequently asked questions about SnapOtter. Privacy, pricing, AI features, file limits, updates, and enterprise licensing.", "Frequently asked questions about SnapOtter. Privacy, pricing, AI features, file limits, updates, and enterprise licensing.",
alternates: { canonical: "https://snapotter.com/faq" }, alternates: { canonical: "https://snapotter.com/faq" },
openGraph: {
title: "FAQ | SnapOtter",
description:
"Frequently asked questions about SnapOtter. Privacy, pricing, AI features, file limits, updates, and enterprise licensing.",
url: "https://snapotter.com/faq",
},
twitter: {
title: "FAQ | SnapOtter",
description:
"Frequently asked questions about SnapOtter. Privacy, pricing, AI features, file limits, updates, and enterprise licensing.",
},
}; };
export default function FaqLayout({ children }: { children: React.ReactNode }) { export default function FaqLayout({ children }: { children: React.ReactNode }) {
+4
View File
@@ -40,6 +40,7 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
site: "@SnapOtterHQ",
title: "SnapOtter | Self-Hosted Image Processing", title: "SnapOtter | Self-Hosted Image Processing",
description: description:
"52 image processing tools with local AI. Runs 100% offline. No data leaves your network.", "52 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
@@ -50,6 +51,9 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className={`${inter.variable} ${nunito.variable}`} suppressHydrationWarning> <html lang="en" className={`${inter.variable} ${nunito.variable}`} suppressHydrationWarning>
<head>
<link rel="llms-txt" href="/llms.txt" />
</head>
<body <body
className="bg-background text-foreground font-[family-name:var(--font-inter)] antialiased" className="bg-background text-foreground font-[family-name:var(--font-inter)] antialiased"
suppressHydrationWarning suppressHydrationWarning
+1
View File
@@ -32,6 +32,7 @@ const websiteJsonLd = {
}, },
sameAs: [ sameAs: [
"https://github.com/snapotter-hq/snapotter", "https://github.com/snapotter-hq/snapotter",
"https://x.com/SnapOtterHQ",
"https://discord.gg/hr3s7HPUsr", "https://discord.gg/hr3s7HPUsr",
"https://hub.docker.com/r/snapotter/snapotter", "https://hub.docker.com/r/snapotter/snapotter",
], ],
+33 -2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { FadeIn } from "@/components/fade-in"; import { FadeIn } from "@/components/fade-in";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import { JsonLd } from "@/components/json-ld";
import { Navbar } from "@/components/navbar"; import { Navbar } from "@/components/navbar";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -9,11 +10,37 @@ export const metadata: Metadata = {
description: description:
"SnapOtter is self-hosted software. We do not process, store, or have access to your images or data. Learn about our privacy practices.", "SnapOtter is self-hosted software. We do not process, store, or have access to your images or data. Learn about our privacy practices.",
alternates: { canonical: "https://snapotter.com/privacy" }, alternates: { canonical: "https://snapotter.com/privacy" },
openGraph: {
title: "Privacy Policy | SnapOtter",
description:
"SnapOtter is self-hosted software. We do not process, store, or have access to your images or data.",
url: "https://snapotter.com/privacy",
},
twitter: {
title: "Privacy Policy | SnapOtter",
description:
"SnapOtter is self-hosted software. We do not process, store, or have access to your images or data.",
},
};
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
{
"@type": "ListItem",
position: 2,
name: "Privacy Policy",
item: "https://snapotter.com/privacy",
},
],
}; };
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<> <>
<JsonLd data={breadcrumbJsonLd} />
<Navbar /> <Navbar />
<main className="pt-16"> <main className="pt-16">
<section className="px-6 py-24 md:py-32"> <section className="px-6 py-24 md:py-32">
@@ -21,7 +48,7 @@ export default function PrivacyPage() {
<FadeIn> <FadeIn>
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">Privacy Policy</h1> <h1 className="text-4xl font-bold tracking-tight md:text-5xl">Privacy Policy</h1>
<p className="mt-6 text-lg text-muted">Last updated: April 24, 2026</p> <p className="mt-6 text-lg text-muted">Last updated: May 18, 2026</p>
</div> </div>
</FadeIn> </FadeIn>
@@ -39,7 +66,11 @@ export default function PrivacyPage() {
<h2 className="text-lg font-semibold text-foreground">Website (snapotter.com)</h2> <h2 className="text-lg font-semibold text-foreground">Website (snapotter.com)</h2>
<ul className="mt-3 list-disc space-y-2 pl-5"> <ul className="mt-3 list-disc space-y-2 pl-5">
<li>We do not use tracking cookies.</li> <li>We do not use tracking cookies.</li>
<li>We do not use third-party analytics on the website.</li> <li>
We use Cloudflare Web Analytics for anonymous, aggregate traffic metrics (page
views, visits, referrers). It uses no cookies, collects no personal data, and
does not track individual users across sites.
</li>
<li> <li>
Contact form submissions are processed via Formspree. Their privacy policy Contact form submissions are processed via Formspree. Their privacy policy
applies to that data. applies to that data.
+35
View File
@@ -0,0 +1,35 @@
import { TOOLS } from "@snapotter/shared";
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
const base = "https://snapotter.com";
const staticPages: MetadataRoute.Sitemap = [
{ url: base, lastModified: new Date(), changeFrequency: "weekly", priority: 1.0 },
{ url: `${base}/faq`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 },
{
url: `${base}/contact`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${base}/privacy`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.3,
},
{ url: `${base}/terms`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
];
const toolPages: MetadataRoute.Sitemap = TOOLS.map((tool) => ({
url: `${base}/tools/${tool.id}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.8,
}));
return [...staticPages, ...toolPages];
}
+27
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { FadeIn } from "@/components/fade-in"; import { FadeIn } from "@/components/fade-in";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import { JsonLd } from "@/components/json-ld";
import { Navbar } from "@/components/navbar"; import { Navbar } from "@/components/navbar";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -9,11 +10,37 @@ export const metadata: Metadata = {
description: description:
"Terms governing your use of the SnapOtter website and self-hosted image processing software.", "Terms governing your use of the SnapOtter website and self-hosted image processing software.",
alternates: { canonical: "https://snapotter.com/terms" }, alternates: { canonical: "https://snapotter.com/terms" },
openGraph: {
title: "Terms and Conditions | SnapOtter",
description:
"Terms governing your use of the SnapOtter website and self-hosted image processing software.",
url: "https://snapotter.com/terms",
},
twitter: {
title: "Terms and Conditions | SnapOtter",
description:
"Terms governing your use of the SnapOtter website and self-hosted image processing software.",
},
};
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
{
"@type": "ListItem",
position: 2,
name: "Terms and Conditions",
item: "https://snapotter.com/terms",
},
],
}; };
export default function TermsPage() { export default function TermsPage() {
return ( return (
<> <>
<JsonLd data={breadcrumbJsonLd} />
<Navbar /> <Navbar />
<main className="pt-16"> <main className="pt-16">
<section className="px-6 py-24 md:py-32"> <section className="px-6 py-24 md:py-32">
+422
View File
@@ -0,0 +1,422 @@
import { CATEGORIES, TOOLS } from "@snapotter/shared";
import { ArrowRight, CheckCircle2, Container, Github, Server, Zap } from "lucide-react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { FadeIn } from "@/components/fade-in";
import { Footer } from "@/components/footer";
import { JsonLd } from "@/components/json-ld";
import { Navbar } from "@/components/navbar";
import { ToolIcon } from "@/components/tool-icon";
import { TOOL_SEO } from "@/data/tool-seo";
interface PageProps {
params: Promise<{ slug: string }>;
}
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
function getTool(slug: string) {
const tool = TOOLS.find((t) => t.id === slug);
if (!tool) return null;
const category = categoryMap.get(tool.category);
const seo = TOOL_SEO[tool.id];
return { tool, category, seo };
}
function getRelatedTools(toolId: string, categoryId: string) {
return TOOLS.filter((t) => t.category === categoryId && t.id !== toolId).slice(0, 4);
}
export function generateStaticParams() {
return TOOLS.map((tool) => ({ slug: tool.id }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const data = getTool(slug);
if (!data) return {};
const { tool, seo } = data;
const title = seo ? `${seo.searchTitle} | SnapOtter` : `${tool.name} | SnapOtter`;
const description = seo?.longDescription ?? tool.description;
return {
title,
description,
alternates: { canonical: `https://snapotter.com/tools/${tool.id}` },
openGraph: {
title,
description,
url: `https://snapotter.com/tools/${tool.id}`,
siteName: "SnapOtter",
type: "website",
locale: "en_US",
images: [
{
url: "/og-image.png",
width: 1280,
height: 640,
alt: `${tool.name} - SnapOtter`,
},
],
},
twitter: {
card: "summary_large_image",
title,
description,
images: ["/og-image.png"],
},
};
}
export default async function ToolPage({ params }: PageProps) {
const { slug } = await params;
const data = getTool(slug);
if (!data) notFound();
const { tool, category, seo } = data;
const related = getRelatedTools(tool.id, tool.category);
const catColor = category?.color ?? "#737373";
const isAiTool = tool.category === "ai";
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://snapotter.com",
},
{
"@type": "ListItem",
position: 2,
name: "Tools",
item: "https://snapotter.com/#features",
},
{
"@type": "ListItem",
position: 3,
name: tool.name,
item: `https://snapotter.com/tools/${tool.id}`,
},
],
};
const softwareJsonLd = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: `SnapOtter ${tool.name}`,
description: seo?.longDescription ?? tool.description,
applicationCategory: "MultimediaApplication",
operatingSystem: "Linux, macOS, Windows (via Docker)",
browserRequirements: "Requires JavaScript",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
license: "https://www.gnu.org/licenses/agpl-3.0.en.html",
url: `https://snapotter.com/tools/${tool.id}`,
...(seo?.features && { featureList: seo.features }),
isPartOf: {
"@type": "SoftwareApplication",
name: "SnapOtter",
url: "https://snapotter.com",
},
};
const howToJsonLd = {
"@context": "https://schema.org",
"@type": "HowTo",
name: `How to use ${tool.name} in SnapOtter`,
description: seo?.longDescription ?? tool.description,
totalTime: "PT1M",
tool: { "@type": "HowToTool", name: "SnapOtter" },
step: [
{
"@type": "HowToStep",
position: 1,
name: "Deploy SnapOtter",
text: "Run SnapOtter with a single Docker command: docker run -p 1349:1349 snapotter/snapotter",
},
{
"@type": "HowToStep",
position: 2,
name: `Open ${tool.name}`,
text: `Navigate to the ${tool.name} tool in SnapOtter's interface.`,
},
{
"@type": "HowToStep",
position: 3,
name: "Upload your image",
text: "Drag and drop your image file or click to browse. Supports JPEG, PNG, WebP, AVIF, TIFF, HEIC, and RAW formats.",
},
{
"@type": "HowToStep",
position: 4,
name: "Adjust settings and process",
text: `Configure ${tool.name} settings to your needs, then click process. Download your result or continue with another tool.`,
},
],
};
const faqJsonLd =
seo?.faqs && seo.faqs.length > 0
? {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: seo.faqs.map((faq) => ({
"@type": "Question",
name: faq.q,
acceptedAnswer: {
"@type": "Answer",
text: faq.a,
},
})),
}
: null;
return (
<>
<JsonLd data={breadcrumbJsonLd} />
<JsonLd data={softwareJsonLd} />
<JsonLd data={howToJsonLd} />
{faqJsonLd && <JsonLd data={faqJsonLd} />}
<Navbar />
<main className="pt-16">
{/* Breadcrumb */}
<nav className="mx-auto max-w-4xl px-6 pt-8" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-muted">
<li>
<a href="/" className="transition-colors hover:text-foreground">
Home
</a>
</li>
<li aria-hidden="true">/</li>
<li>
<a href="/#features" className="transition-colors hover:text-foreground">
Tools
</a>
</li>
<li aria-hidden="true">/</li>
<li className="text-foreground font-medium">{tool.name}</li>
</ol>
</nav>
{/* Hero */}
<section className="mx-auto max-w-4xl px-6 pt-12 pb-16">
<FadeIn>
<div className="flex items-center gap-3 mb-6">
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl"
style={{ backgroundColor: `${catColor}14` }}
>
<ToolIcon name={tool.icon} color={catColor} size={28} />
</div>
<span
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ backgroundColor: `${catColor}14`, color: catColor }}
>
{category?.name}
</span>
{isAiTool && (
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
AI-Powered
</span>
)}
</div>
<h1 className="font-[family-name:var(--font-nunito)] text-4xl font-bold tracking-tight md:text-5xl">
{tool.name}
</h1>
<p className="mt-4 text-lg leading-relaxed text-muted md:text-xl">
{seo?.longDescription ?? tool.description}
</p>
<div className="mt-8 flex flex-wrap gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-accent px-6 py-3 text-sm font-medium text-accent-foreground transition-colors hover:bg-accent-hover"
>
<Container size={18} />
Deploy with Docker
</a>
<a
href="https://github.com/snapotter-hq/snapotter"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-medium transition-colors hover:bg-background-alt"
>
<Github size={18} />
View Source
</a>
</div>
</FadeIn>
</section>
{/* Features */}
{seo?.features && seo.features.length > 0 && (
<section className="border-t border-border bg-background-alt py-16">
<div className="mx-auto max-w-4xl px-6">
<FadeIn>
<div className="flex items-center gap-2 mb-6">
<Zap size={20} style={{ color: catColor }} />
<h2 className="font-[family-name:var(--font-nunito)] text-2xl font-bold tracking-tight md:text-3xl">
Features
</h2>
</div>
<ul className="grid gap-3 sm:grid-cols-2">
{seo.features.map((feature) => (
<li key={feature} className="flex gap-3">
<CheckCircle2
size={18}
className="mt-0.5 shrink-0"
style={{ color: catColor }}
/>
<span className="text-sm leading-relaxed">{feature}</span>
</li>
))}
</ul>
</FadeIn>
</div>
</section>
)}
{/* Use Cases */}
{seo?.useCases && seo.useCases.length > 0 && (
<section className="py-16">
<div className="mx-auto max-w-4xl px-6">
<FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-2xl font-bold tracking-tight md:text-3xl">
What you can do
</h2>
<ul className="mt-8 grid gap-4 sm:grid-cols-2">
{seo.useCases.map((useCase) => (
<li key={useCase} className="flex gap-3">
<ArrowRight size={16} className="mt-1 shrink-0 text-muted" />
<span className="text-sm leading-relaxed text-muted">{useCase}</span>
</li>
))}
</ul>
</FadeIn>
</div>
</section>
)}
{/* Self-Hosted Callout - varied by tool type */}
<section className="border-t border-border bg-background-alt py-16">
<div className="mx-auto max-w-4xl px-6">
<FadeIn>
<div className="flex items-start gap-4">
<Server size={24} className="mt-1 shrink-0 text-accent" />
<div>
<h2 className="font-[family-name:var(--font-nunito)] text-xl font-bold">
{isAiTool
? "AI that runs on your hardware. No cloud APIs, no usage limits."
: "Self-hosted. Your images never leave your network."}
</h2>
<p className="mt-2 text-sm leading-relaxed text-muted">
{isAiTool
? `Unlike cloud AI services, SnapOtter's ${tool.name} runs the ML model directly on your server. Your images are processed locally with no data sent to external APIs. No per-image fees, no rate limits, no privacy concerns. Deploy once with Docker and use it as much as you need.`
: `SnapOtter runs entirely on your own infrastructure. Images processed with ${tool.name} are never uploaded to third-party servers. Deploy a single Docker container and process images with full privacy, no watermarks, and no usage limits. Open source under AGPL-3.0.`}
</p>
</div>
</div>
</FadeIn>
</div>
</section>
{/* FAQs */}
{seo?.faqs && seo.faqs.length > 0 && (
<section className="py-16">
<div className="mx-auto max-w-4xl px-6">
<FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-2xl font-bold tracking-tight md:text-3xl">
Frequently asked questions
</h2>
<dl className="mt-8 space-y-6">
{seo.faqs.map((faq) => (
<div key={faq.q} className="rounded-xl border border-border p-6">
<dt className="text-sm font-semibold">{faq.q}</dt>
<dd className="mt-3 text-sm leading-relaxed text-muted">{faq.a}</dd>
</div>
))}
</dl>
</FadeIn>
</div>
</section>
)}
{/* Related Tools */}
{related.length > 0 && (
<section className="border-t border-border bg-background-alt py-16">
<div className="mx-auto max-w-4xl px-6">
<FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-2xl font-bold tracking-tight">
More {category?.name} tools
</h2>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{related.map((t) => {
const relCat = categoryMap.get(t.category);
return (
<a
key={t.id}
href={`/tools/${t.id}`}
className="group flex flex-col rounded-xl border border-border bg-background p-5 transition-all hover:border-accent hover:shadow-md"
>
<ToolIcon name={t.icon} color={relCat?.color ?? "#737373"} size={24} />
<span className="mt-3 text-sm font-bold">{t.name}</span>
<p className="mt-1.5 text-xs leading-relaxed text-muted">{t.description}</p>
<span className="mt-auto pt-4 inline-flex items-center gap-1 text-xs font-medium text-accent group-hover:gap-2 transition-all">
Learn more <ArrowRight size={12} />
</span>
</a>
);
})}
</div>
</FadeIn>
</div>
</section>
)}
{/* Bottom CTA */}
<section className="py-20">
<div className="mx-auto max-w-4xl px-6 text-center">
<FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-3xl font-bold tracking-tight">
Ready to try {tool.name}?
</h2>
<p className="mx-auto mt-4 max-w-lg text-muted">
Deploy SnapOtter in under a minute. All 52 tools included. Open source and free
forever.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-accent px-6 py-3 text-sm font-medium text-accent-foreground transition-colors hover:bg-accent-hover"
>
Get Started
<ArrowRight size={16} />
</a>
<a
href="/#pricing"
className="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-medium transition-colors hover:bg-background-alt"
>
View Pricing
</a>
</div>
</FadeIn>
</div>
</section>
</main>
<Footer />
</>
);
}
+17 -399
View File
@@ -1,399 +1,14 @@
"use client"; "use client";
import type { LucideIcon } from "lucide-react"; import { CATEGORIES, TOOLS } from "@snapotter/shared";
import { import { icons, Search } from "lucide-react";
AppWindow,
AudioLines,
Code,
Columns,
Columns2,
Copy,
Crop,
Droplets,
Eraser,
Eye,
EyeOff,
FileEdit,
FileImage,
FileOutput,
FileText,
Film,
Focus,
Frame,
Globe,
Grid3x3,
ImagePlus,
Info,
Laugh,
Layers,
LayoutGrid,
Maximize2,
Minimize2,
Palette,
PenLine,
PenTool,
Pipette,
QrCode,
RotateCw,
Scaling,
Scan,
ScanFace,
ScanLine,
ScanText,
Search,
ShieldOff,
SlidersHorizontal,
Sparkles,
TextCursorInput,
Type,
Undo2,
UserCheck,
Wand2,
ZoomIn,
} from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FadeIn } from "./fade-in"; import { FadeIn } from "./fade-in";
const categories = [ const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
{ id: "essentials", name: "Essentials", color: "#3B82F6" },
{ id: "optimization", name: "Optimization", color: "#10B981" },
{ id: "adjustments", name: "Adjustments", color: "#8B5CF6" },
{ id: "watermark", name: "Watermark & Overlay", color: "#EF4444" },
{ id: "utilities", name: "Utilities", color: "#6366F1" },
{ id: "layout", name: "Layout & Composition", color: "#EC4899" },
{ id: "format", name: "Format & Conversion", color: "#14B8A6" },
{ id: "ai", name: "AI Tools", color: "#F59E0B" },
];
const tools: { name: string; description: string; category: string; icon: LucideIcon }[] = [
// Essentials
{
name: "Resize",
description: "Resize by pixels, percentage, or social media presets",
category: "essentials",
icon: Maximize2,
},
{
name: "Crop",
description: "Freeform crop, aspect ratio presets, shape crop",
category: "essentials",
icon: Crop,
},
{
name: "Rotate & Flip",
description: "Rotate, flip, and straighten images",
category: "essentials",
icon: RotateCw,
},
{
name: "Convert",
description: "Convert between image formats",
category: "essentials",
icon: FileOutput,
},
{
name: "Compress",
description: "Reduce file size by quality or target size",
category: "essentials",
icon: Minimize2,
},
// Optimization
{
name: "Optimize for Web",
description:
"Optimize images for web with format conversion, quality control, and live preview",
category: "optimization",
icon: Globe,
},
{
name: "Remove Metadata",
description: "Remove EXIF, GPS, and camera info",
category: "optimization",
icon: ShieldOff,
},
{
name: "Edit Metadata",
description: "Edit EXIF, GPS, and camera info",
category: "optimization",
icon: PenLine,
},
{
name: "Bulk Rename",
description: "Rename multiple files with patterns",
category: "optimization",
icon: FileEdit,
},
{
name: "Image to PDF",
description: "Combine images into a PDF document",
category: "optimization",
icon: FileText,
},
{
name: "Favicon Generator",
description: "Generate all favicon and app icon sizes",
category: "optimization",
icon: AppWindow,
},
// Adjustments
{
name: "Adjust Colors",
description: "Brightness, contrast, exposure, saturation, temperature, sharpness, and effects",
category: "adjustments",
icon: SlidersHorizontal,
},
{
name: "Sharpening",
description: "Adaptive, unsharp mask, and high-pass sharpening with presets",
category: "adjustments",
icon: Focus,
},
{
name: "Replace & Invert Color",
description: "Replace specific colors or invert",
category: "adjustments",
icon: Pipette,
},
{
name: "Color Blindness Simulation",
description: "Simulate how images appear under different types of color vision deficiency",
category: "adjustments",
icon: Eye,
},
// AI Tools
{
name: "Remove Background",
description: "AI-powered background removal",
category: "ai",
icon: Eraser,
},
{
name: "Image Upscaling",
description: "AI super-resolution enhancement",
category: "ai",
icon: ZoomIn,
},
{
name: "Object Eraser",
description: "Remove unwanted objects with AI",
category: "ai",
icon: Wand2,
},
{
name: "OCR / Text Extraction",
description: "Extract text from images",
category: "ai",
icon: ScanText,
},
{
name: "Face / PII Blur",
description: "Auto-detect and blur faces and sensitive info",
category: "ai",
icon: EyeOff,
},
{
name: "Smart Crop",
description: "Smart subject, face, or trim-based cropping",
category: "ai",
icon: Scan,
},
{
name: "Image Enhancement",
description: "One-click auto-improve with smart analysis",
category: "ai",
icon: Sparkles,
},
{
name: "Face Enhancement",
description: "Restore and enhance faces with AI",
category: "ai",
icon: ScanFace,
},
{
name: "AI Colorization",
description: "Convert B&W photos to full color with AI",
category: "ai",
icon: Palette,
},
{
name: "Noise Removal",
description: "AI-powered noise and grain removal",
category: "ai",
icon: AudioLines,
},
{
name: "Red Eye Removal",
description: "AI-detect and fix red eye in flash photos",
category: "ai",
icon: Eye,
},
{
name: "Photo Restoration",
description: "Fix scratches, tears, and damage on old photos with AI",
category: "ai",
icon: Undo2,
},
{
name: "Passport Photo",
description: "AI-powered passport and ID photo generator",
category: "ai",
icon: UserCheck,
},
{
name: "Content-Aware Resize",
description: "Seam carving resize that preserves important content",
category: "ai",
icon: Scaling,
},
{
name: "AI Canvas Expand",
description: "Expand canvas with AI-powered fill",
category: "ai",
icon: Maximize2,
},
{
name: "PNG Transparency Fixer",
description: "Fix fake transparent PNGs with AI matting",
category: "ai",
icon: Droplets,
},
// Watermark & Overlay
{
name: "Text Watermark",
description: "Add text watermark overlay",
category: "watermark",
icon: Type,
},
{
name: "Image Watermark",
description: "Overlay a logo as watermark",
category: "watermark",
icon: ImagePlus,
},
{
name: "Text Overlay",
description: "Add styled text to images",
category: "watermark",
icon: TextCursorInput,
},
{
name: "Image Composition",
description: "Layer images with position and opacity",
category: "watermark",
icon: Layers,
},
{
name: "Meme Generator",
description: "Create memes with templates and custom text",
category: "watermark",
icon: Laugh,
},
// Utilities
{
name: "Image Info",
description: "View all metadata and image properties",
category: "utilities",
icon: Info,
},
{
name: "Image Compare",
description: "Side-by-side comparison of two images",
category: "utilities",
icon: Columns2,
},
{
name: "Find Duplicates",
description: "Detect duplicate and near-duplicate images",
category: "utilities",
icon: Copy,
},
{
name: "Color Palette",
description: "Extract dominant colors from image",
category: "utilities",
icon: Palette,
},
{
name: "QR Code Generator",
description: "Generate styled QR codes with custom colors, patterns, and logos",
category: "utilities",
icon: QrCode,
},
{
name: "Barcode Reader",
description: "Scan images for QR codes, barcodes, and 2D codes",
category: "utilities",
icon: ScanLine,
},
{
name: "Image to Base64",
description: "Convert images to base64 strings for embedding in HTML, CSS, and more",
category: "utilities",
icon: Code,
},
// Layout & Composition
{
name: "Collage / Grid",
description: "Combine images into beautiful grid collages with 25+ templates",
category: "layout",
icon: LayoutGrid,
},
{
name: "Stitch / Combine",
description: "Join images side by side, stacked, or in a grid",
category: "layout",
icon: Columns,
},
{
name: "Image Splitting",
description: "Split images into grid tiles or by pixel size with live preview",
category: "layout",
icon: Grid3x3,
},
{
name: "Border & Frame",
description: "Add borders, rounded corners, shadows",
category: "layout",
icon: Frame,
},
{
name: "Beautify Screenshot",
description: "Add gradient backgrounds, device frames, shadows, and social media sizing",
category: "layout",
icon: Sparkles,
},
// Format & Conversion
{
name: "SVG to Raster",
description: "Convert SVG to PNG, JPEG, WebP, AVIF, TIFF, GIF, or HEIF at custom scale and DPI",
category: "format",
icon: FileImage,
},
{
name: "Image to SVG",
description: "Vectorize images using tracing",
category: "format",
icon: PenTool,
},
{
name: "GIF Tools",
description:
"Resize, optimize, change speed, reverse, extract frames, and rotate animated GIFs",
category: "format",
icon: Film,
},
{
name: "PDF to Image",
description: "Convert PDF pages to images",
category: "format",
icon: FileOutput,
},
];
const categoryMap = new Map(categories.map((c) => [c.id, c]));
function getCategoryCount(id: string) { function getCategoryCount(id: string) {
return tools.filter((t) => t.category === id).length; return TOOLS.filter((t) => t.category === id).length;
} }
export function BentoGrid() { export function BentoGrid() {
@@ -402,7 +17,7 @@ export function BentoGrid() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.toLowerCase().trim(); const q = search.toLowerCase().trim();
return tools.filter((tool) => { return TOOLS.filter((tool) => {
if (activeCategory !== "all" && tool.category !== activeCategory) { if (activeCategory !== "all" && tool.category !== activeCategory) {
return false; return false;
} }
@@ -453,9 +68,9 @@ export function BentoGrid() {
: "border border-border hover:bg-background-alt" : "border border-border hover:bg-background-alt"
}`} }`}
> >
All ({tools.length}) All ({TOOLS.length})
</button> </button>
{categories.map((cat) => ( {CATEGORIES.map((cat) => (
<button <button
key={cat.id} key={cat.id}
type="button" type="button"
@@ -473,7 +88,7 @@ export function BentoGrid() {
{/* Result count */} {/* Result count */}
<p className="mt-6 text-center text-sm text-muted"> <p className="mt-6 text-center text-sm text-muted">
Showing {filtered.length} of {tools.length} tools Showing {filtered.length} of {TOOLS.length} tools
</p> </p>
{/* Tool grid */} {/* Tool grid */}
@@ -481,21 +96,24 @@ export function BentoGrid() {
<div className="mt-8 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5"> <div className="mt-8 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{filtered.map((tool) => { {filtered.map((tool) => {
const cat = categoryMap.get(tool.category); const cat = categoryMap.get(tool.category);
const Icon = tool.icon; const Icon = icons[tool.icon as keyof typeof icons];
return ( return (
<div <a
key={tool.name} key={tool.id}
className="tool-card flex flex-col items-center rounded-xl border border-border bg-background-alt px-4 py-6 text-center transition-all hover:shadow-md" href={`/tools/${tool.id}`}
className="tool-card flex flex-col items-center rounded-xl border border-border bg-background-alt px-4 py-6 text-center transition-all hover:shadow-md no-underline text-inherit"
style={ style={
{ {
"--cat-color": cat?.color, "--cat-color": cat?.color,
} as React.CSSProperties } as React.CSSProperties
} }
> >
<Icon size={30} style={{ color: cat?.color }} className="mb-3 shrink-0" /> {Icon && (
<Icon size={30} style={{ color: cat?.color }} className="mb-3 shrink-0" />
)}
<span className="font-bold text-sm">{tool.name}</span> <span className="font-bold text-sm">{tool.name}</span>
<p className="mt-1.5 text-xs leading-relaxed text-muted">{tool.description}</p> <p className="mt-1.5 text-xs leading-relaxed text-muted">{tool.description}</p>
</div> </a>
); );
})} })}
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { icons } from "lucide-react";
export function ToolIcon({
name,
color,
size = 32,
}: {
name: string;
color: string;
size?: number;
}) {
const Icon = icons[name as keyof typeof icons];
if (!Icon) return null;
return <Icon size={size} style={{ color }} />;
}
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -283,15 +283,16 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
await refreshBundles(); await refreshBundles();
} }
// Process the queue: re-read which bundles still need installing // Process the queue sequentially. After each install, wait briefly
// (the one that was active may have just finished). // so the backend lock file is fully released before the next attempt.
// If a bundle fails, re-enqueue it for one retry.
const retried = new Set<string>();
while (true) { while (true) {
const q = get().queued; const q = get().queued;
if (q.length === 0) break; if (q.length === 0) break;
const nextId = q[0]; const nextId = q[0];
set({ queued: q.slice(1) }); set({ queued: q.slice(1) });
// Skip if it got installed in the meantime
const current = get().bundles.find((b) => b.id === nextId); const current = get().bundles.find((b) => b.id === nextId);
if (current?.status === "installed") continue; if (current?.status === "installed") continue;
@@ -299,6 +300,18 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
completionRefs[nextId] = resolve; completionRefs[nextId] = resolve;
get().installBundle(nextId); get().installBundle(nextId);
}); });
await refreshBundles();
const after = get().bundles.find((b) => b.id === nextId);
if (after?.status !== "installed" && !retried.has(nextId)) {
retried.add(nextId);
const errors = { ...get().errors };
delete errors[nextId];
set({ queued: [...get().queued, nextId], errors });
}
// Brief pause to let the backend fully release the install lock
await new Promise((r) => setTimeout(r, 2000));
} }
set({ queued: [], installAllActive: false }); set({ queued: [], installAllActive: false });