mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -134,7 +134,25 @@ interface LockData {
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
const LOCK_STALE_MS = 45 * 60 * 1000;
|
||||
|
||||
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 {
|
||||
const fd = openSync(LOCK_PATH, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
||||
const lock: LockData = {
|
||||
|
||||
@@ -219,6 +219,11 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
|
||||
// Take over the response from Fastify for SSE streaming
|
||||
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
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
|
||||
@@ -26,12 +26,25 @@ export default defineConfig({
|
||||
["meta", { property: "og:image:width", content: "1280" }],
|
||||
["meta", { property: "og:image:height", content: "640" }],
|
||||
["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", { 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" }],
|
||||
],
|
||||
|
||||
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: {
|
||||
plugins: [
|
||||
llmstxt({
|
||||
@@ -78,9 +91,10 @@ export default defineConfig({
|
||||
{ text: "Home", link: "/" },
|
||||
{ text: "Guide", link: "/guide/getting-started" },
|
||||
{ text: "API Reference", link: "/api/rest" },
|
||||
{ text: "Changelog", link: "/changelog" },
|
||||
{
|
||||
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: "Project",
|
||||
items: [{ text: "Changelog", link: "/changelog" }],
|
||||
},
|
||||
],
|
||||
|
||||
search: {
|
||||
|
||||
@@ -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).
|
||||
@@ -1,4 +1,25 @@
|
||||
User-agent: *
|
||||
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
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
transpilePackages: ["@snapotter/shared"],
|
||||
webpack: (config) => {
|
||||
config.resolve.extensionAlias = {
|
||||
".js": [".ts", ".tsx", ".js"],
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"clean": "rm -rf .next"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"framer-motion": "^11.18.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^15.5.18",
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,26 @@
|
||||
User-agent: *
|
||||
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://docs.snapotter.com/sitemap.xml
|
||||
|
||||
@@ -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>
|
||||
@@ -5,6 +5,17 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"Book a demo, get deployment help, or discuss enterprise licensing for SnapOtter self-hosted image processing.",
|
||||
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 }) {
|
||||
|
||||
@@ -5,6 +5,17 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"Frequently asked questions about SnapOtter. Privacy, pricing, AI features, file limits, updates, and enterprise licensing.",
|
||||
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 }) {
|
||||
|
||||
@@ -40,6 +40,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@SnapOtterHQ",
|
||||
title: "SnapOtter | Self-Hosted Image Processing",
|
||||
description:
|
||||
"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 }) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${nunito.variable}`} suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="llms-txt" href="/llms.txt" />
|
||||
</head>
|
||||
<body
|
||||
className="bg-background text-foreground font-[family-name:var(--font-inter)] antialiased"
|
||||
suppressHydrationWarning
|
||||
|
||||
@@ -32,6 +32,7 @@ const websiteJsonLd = {
|
||||
},
|
||||
sameAs: [
|
||||
"https://github.com/snapotter-hq/snapotter",
|
||||
"https://x.com/SnapOtterHQ",
|
||||
"https://discord.gg/hr3s7HPUsr",
|
||||
"https://hub.docker.com/r/snapotter/snapotter",
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
|
||||
import { FadeIn } from "@/components/fade-in";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { JsonLd } from "@/components/json-ld";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -9,11 +10,37 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"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" },
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<Navbar />
|
||||
<main className="pt-16">
|
||||
<section className="px-6 py-24 md:py-32">
|
||||
@@ -21,7 +48,7 @@ export default function PrivacyPage() {
|
||||
<FadeIn>
|
||||
<div className="text-center">
|
||||
<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>
|
||||
</FadeIn>
|
||||
|
||||
@@ -39,7 +66,11 @@ export default function PrivacyPage() {
|
||||
<h2 className="text-lg font-semibold text-foreground">Website (snapotter.com)</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-5">
|
||||
<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>
|
||||
Contact form submissions are processed via Formspree. Their privacy policy
|
||||
applies to that data.
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
|
||||
import { FadeIn } from "@/components/fade-in";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { JsonLd } from "@/components/json-ld";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -9,11 +10,37 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"Terms governing your use of the SnapOtter website and self-hosted image processing software.",
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<Navbar />
|
||||
<main className="pt-16">
|
||||
<section className="px-6 py-24 md:py-32">
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,399 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
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 { CATEGORIES, TOOLS } from "@snapotter/shared";
|
||||
import { icons, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FadeIn } from "./fade-in";
|
||||
|
||||
const categories = [
|
||||
{ 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]));
|
||||
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
|
||||
|
||||
function getCategoryCount(id: string) {
|
||||
return tools.filter((t) => t.category === id).length;
|
||||
return TOOLS.filter((t) => t.category === id).length;
|
||||
}
|
||||
|
||||
export function BentoGrid() {
|
||||
@@ -402,7 +17,7 @@ export function BentoGrid() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return tools.filter((tool) => {
|
||||
return TOOLS.filter((tool) => {
|
||||
if (activeCategory !== "all" && tool.category !== activeCategory) {
|
||||
return false;
|
||||
}
|
||||
@@ -453,9 +68,9 @@ export function BentoGrid() {
|
||||
: "border border-border hover:bg-background-alt"
|
||||
}`}
|
||||
>
|
||||
All ({tools.length})
|
||||
All ({TOOLS.length})
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
@@ -473,7 +88,7 @@ export function BentoGrid() {
|
||||
|
||||
{/* Result count */}
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{filtered.map((tool) => {
|
||||
const cat = categoryMap.get(tool.category);
|
||||
const Icon = tool.icon;
|
||||
const Icon = icons[tool.icon as keyof typeof icons];
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
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"
|
||||
<a
|
||||
key={tool.id}
|
||||
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={
|
||||
{
|
||||
"--cat-color": cat?.color,
|
||||
} 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>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted">{tool.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -283,15 +283,16 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
await refreshBundles();
|
||||
}
|
||||
|
||||
// Process the queue: re-read which bundles still need installing
|
||||
// (the one that was active may have just finished).
|
||||
// Process the queue sequentially. After each install, wait briefly
|
||||
// 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) {
|
||||
const q = get().queued;
|
||||
if (q.length === 0) break;
|
||||
const nextId = q[0];
|
||||
set({ queued: q.slice(1) });
|
||||
|
||||
// Skip if it got installed in the meantime
|
||||
const current = get().bundles.find((b) => b.id === nextId);
|
||||
if (current?.status === "installed") continue;
|
||||
|
||||
@@ -299,6 +300,18 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
completionRefs[nextId] = resolve;
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user