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;
}
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 = {
+5
View File
@@ -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",
+20 -2
View File
@@ -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: {
+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: *
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
+7
View File
@@ -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;
+1
View File
@@ -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",
+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: *
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
-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:
"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 }) {
+11
View File
@@ -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 }) {
+4
View File
@@ -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
+1
View File
@@ -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",
],
+33 -2
View File
@@ -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.
+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 { 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">
+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";
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>
+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();
}
// 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 });