feat(landing): migrate from Next.js to Astro 5

Zero client JS, 165 static pages, self-hosted fonts, Otter Orange
design system. Includes tool SEO data for all 157 tools, enterprise
page, and updated e2e landing tests.
This commit is contained in:
SnapOtter
2026-06-14 16:50:20 +08:00
parent f9ed5d125a
commit 8403222d08
74 changed files with 9157 additions and 3404 deletions
+37
View File
@@ -0,0 +1,37 @@
---
import Footer from "@/components/Footer.astro";
import Navbar from "@/components/Navbar.astro";
import Base from "@/layouts/Base.astro";
---
<Base
title="Page Not Found | SnapOtter"
description="The page you are looking for does not exist."
noindex={true}
>
<Navbar />
<main id="main" class="flex min-h-[60vh] flex-col items-center justify-center px-6 pt-32 pb-20 text-center">
<div class="reveal">
<p class="text-6xl font-bold text-primary">404</p>
<h1 class="mt-4 font-display text-3xl font-bold tracking-tight md:text-4xl">
Page not found
</h1>
<p class="mt-4 text-lg text-muted">
The page you are looking for does not exist or has been moved.
</p>
<a
href="/"
class="btn-primary mt-8 inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Back to homepage
</a>
</div>
</main>
<Footer />
</Base>
+122
View File
@@ -0,0 +1,122 @@
---
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import Base from "@/layouts/Base.astro";
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
{ "@type": "ListItem", position: 2, name: "Contact", item: "https://snapotter.com/contact" },
],
};
const benefits = [
{
title: "Live Demo",
description: "See SnapOtter in action with a personalized walkthrough.",
icon: '<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
},
{
title: "Deployment Support",
description: "Expert help setting up SnapOtter on your infrastructure.",
icon: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
},
{
title: "Enterprise Licensing",
description: "Custom agreements for proprietary use, OEM, and commercial distribution.",
icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
},
];
const subjects = ["Book a Demo", "Enterprise Licensing", "Deployment Help", "General Inquiry"];
---
<Base
title="Contact | SnapOtter"
description="Book a demo, get deployment support, or discuss enterprise licensing for SnapOtter."
canonical="https://snapotter.com/contact"
>
<Fragment slot="head">
<JsonLd data={breadcrumbJsonLd} />
</Fragment>
<Navbar />
<main id="main" class="px-6 pt-32 pb-20 md:pt-40 md:pb-28">
<div class="mx-auto grid max-w-5xl gap-16 md:grid-cols-2">
<!-- Left column -->
<div class="reveal">
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">Get in touch</h1>
<p class="mt-6 text-lg leading-relaxed text-muted">
Whether you need a demo, have questions about deployment, or want to discuss enterprise licensing, we are here to help.
</p>
<div class="mt-12 space-y-4">
{benefits.map((item) => (
<div class="flex gap-4 rounded-xl border border-border bg-surface p-5">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<svg class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={item.icon} />
</div>
<div>
<h3 class="font-display font-semibold">{item.title}</h3>
<p class="mt-1 text-sm leading-relaxed text-muted">{item.description}</p>
</div>
</div>
))}
</div>
<p class="mt-10 text-sm text-muted">
Or email us directly at <a href="mailto:contact@snapotter.com" class="font-medium text-primary hover:underline">contact@snapotter.com</a>
</p>
</div>
<!-- Right column: Contact form -->
<div class="reveal reveal-delay-1">
<form
action="https://formspree.io/f/mykllwek"
method="POST"
class="space-y-5 rounded-2xl border border-border bg-surface p-8"
>
<div>
<label for="name" class="block text-sm font-medium">Name</label>
<input type="text" id="name" name="name" required class="mt-1.5 w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none" />
</div>
<div>
<label for="email" class="block text-sm font-medium">Email</label>
<input type="email" id="email" name="email" required class="mt-1.5 w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none" />
</div>
<div>
<label for="company" class="block text-sm font-medium">Company</label>
<input type="text" id="company" name="company" class="mt-1.5 w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none" />
</div>
<div>
<label for="subject" class="block text-sm font-medium">Subject</label>
<select id="subject" name="subject" required class="mt-1.5 w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none">
<option value="" disabled selected>Select a topic</option>
{subjects.map((s) => (
<option value={s}>{s}</option>
))}
</select>
</div>
<div>
<label for="message" class="block text-sm font-medium">Message</label>
<textarea id="message" name="message" rows={5} required class="mt-1.5 w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none resize-none"></textarea>
</div>
<button type="submit" class="btn-primary w-full rounded-lg px-6 py-3 text-sm font-semibold">
Send Message
</button>
</form>
</div>
</div>
</main>
<Footer />
</Base>
+428
View File
@@ -0,0 +1,428 @@
---
import { TOOLS } from "@snapotter/shared";
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import Base from "@/layouts/Base.astro";
const toolCount = TOOLS.length;
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
{
"@type": "ListItem",
position: 2,
name: "Enterprise",
item: "https://snapotter.com/enterprise",
},
],
};
const productJsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: "SnapOtter Enterprise",
description: `Enterprise-grade self-hosted file processing suite with ${toolCount} tools, SAML SSO, audit logging, per-tool permissions, and multi-tenancy.`,
brand: { "@type": "Organization", name: "SnapOtter" },
url: "https://snapotter.com/enterprise",
offers: {
"@type": "Offer",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
url: "https://snapotter.com/contact",
},
applicationCategory: "UtilitiesApplication",
operatingSystem: "Docker, Linux, Kubernetes",
};
const whySelfHosted = [
{
title: "Compliance by Architecture",
description:
"GDPR, HIPAA, CCPA. Your files never leave your network. No data processing agreements needed with third parties.",
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
},
{
title: "Zero External Dependencies",
description:
"No API keys, no cloud services, no internet required. Works in air-gapped environments and classified networks.",
icon: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
},
{
title: "Total Cost Control",
description:
"No per-user pricing, no per-file charges. Deploy once, use unlimited. Your hardware, your capacity.",
icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
},
];
const identityProviders = ["Okta", "Azure AD", "Google Workspace", "Any OIDC Provider"];
const permissionMatrix = {
roles: ["Admin", "Editor", "User"],
features: ["All Tools", "Pipelines", "API Keys", "User Mgmt", "Audit Log"],
access: [
[true, true, true, true, true],
[true, true, true, false, false],
[true, false, false, false, false],
],
};
const storageProviders = [
{ name: "AWS S3", color: "text-[#FF9900]" },
{ name: "MinIO", color: "text-[#C72C48]" },
{ name: "Cloudflare R2", color: "text-[#F6821F]" },
{ name: "Local Storage", color: "text-primary" },
];
const deploymentTargets = [
{
name: "Docker",
icon: '<path d="M13.98 11.08h2.12a.19.19 0 0 0 .19-.19V9.01a.19.19 0 0 0-.19-.19h-2.12a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19m-2.95-5.43h2.12a.19.19 0 0 0 .19-.19V3.58a.19.19 0 0 0-.19-.19h-2.12a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19m0 2.71h2.12a.19.19 0 0 0 .19-.19V6.29a.19.19 0 0 0-.19-.19h-2.12a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19m-2.93 0h2.12a.19.19 0 0 0 .19-.19V6.29a.19.19 0 0 0-.19-.19H8.1a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19m-2.96 0h2.12a.19.19 0 0 0 .19-.19V6.29a.19.19 0 0 0-.19-.19H5.14a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19m5.89 2.72h2.12a.19.19 0 0 0 .19-.19V9.01a.19.19 0 0 0-.19-.19h-2.12a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19m-2.93 0h2.12a.19.19 0 0 0 .19-.19V9.01a.19.19 0 0 0-.19-.19H8.1a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19m-2.96 0h2.12a.19.19 0 0 0 .19-.19V9.01a.19.19 0 0 0-.19-.19H5.14a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19m-2.92 0h2.12a.19.19 0 0 0 .19-.19V9.01a.19.19 0 0 0-.19-.19H2.22a.19.19 0 0 0-.19.19v1.88c0 .1.09.19.19.19M24 11.52c-.48-.41-1.59-.55-2.44-.38-.11-.79-.56-1.47-1.09-2.04l-.22-.2-.21.2c-.44.42-.7.99-.79 1.59a2.36 2.36 0 0 0 .33 1.72 3.46 3.46 0 0 1-1.62.4H.57a.56.56 0 0 0-.57.56c.02 2.19.36 4.37 1.37 6.3 1.1 2.08 2.83 3.44 5.28 4.14A17.5 17.5 0 0 0 10 24c2.19 0 4.45-.34 6.38-1.45 1.57-.9 2.83-2.27 3.68-3.93.57.04 1.81.06 3.04-.71.06-.04.14-.11.2-.17l.1-.17-.4-.25z"/>',
},
{
name: "Kubernetes",
icon: '<path d="M10.204 14.35l.007.01-.999 2.413a5.17 5.17 0 0 1-2.075-2.597l2.578-.437.004.005a.44.44 0 0 1 .484.606zm-.833-2.129a.44.44 0 0 0 .173-.756l.002-.011L7.585 9.7a5.17 5.17 0 0 0-.238 3.24l1.938-1.04.086.32zm1.926-1.621a.44.44 0 0 0 .752-.19h.01l.693-2.555a5.17 5.17 0 0 0-2.81 1.395l1.355 1.35zm1.474.639a.44.44 0 0 0-.064.776l-.003.01 1.956 1.753a5.17 5.17 0 0 0 .237-3.24l-2.127.7zm-.635 1.965a.44.44 0 0 0-.753.19h-.01l-.692 2.554a5.17 5.17 0 0 0 2.81-1.396l-1.355-1.348zM12 5.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zm0-2a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17z"/>',
},
{
name: "AWS",
icon: '<path d="M6.763 10.036c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 0 1-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 0 1-.287-.375 6.18 6.18 0 0 1-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.391-.384-.59-.894-.59-1.533 0-.678.239-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.032-.863.104-.296.072-.583.16-.862.272a2.287 2.287 0 0 1-.28.104.488.488 0 0 1-.127.024c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 0 1 .224-.167c.279-.144.614-.264 1.005-.36a4.84 4.84 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144.287-.096.543-.271.758-.51.128-.152.224-.32.272-.512.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 0 0-.735-.136 6.02 6.02 0 0 0-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 5.55a1.398 1.398 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 0 1 .32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 0 1 .311-.08h.743c.128 0 .2.064.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 0 1-.056.2l-1.923 6.17c-.048.16-.104.264-.168.312a.52.52 0 0 1-.303.08h-.687c-.151 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32l-1.238-5.148-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.319.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758.777.777 0 0 0-.215-.559c-.144-.151-.415-.287-.806-.399l-1.157-.36c-.583-.183-1.013-.455-1.277-.815a1.903 1.903 0 0 1-.4-1.157c0-.335.073-.63.216-.886.144-.255.336-.479.575-.654.24-.184.51-.32.83-.415A3.64 3.64 0 0 1 18.2 6.2c.176 0 .359.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 0 1 .24.2.43.43 0 0 1 .071.263v.375c0 .168-.064.256-.184.256a.83.83 0 0 1-.303-.096 3.652 3.652 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.695 0 .224.08.416.24.567.16.152.455.304.878.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926-.144.272-.336.511-.583.703-.248.2-.543.343-.886.447-.36.111-.742.167-1.142.167z"/><path d="M21.698 16.793c-2.812 2.08-6.89 3.187-10.399 3.187-4.92 0-9.35-1.82-12.7-4.843-.263-.24-.032-.567.287-.383 3.616 2.104 8.09 3.372 12.708 3.372 3.117 0 6.538-.647 9.693-1.987.471-.208.87.311.41.654z"/><path d="M22.874 15.416c-.36-.463-2.38-.223-3.29-.112-.272.032-.312-.208-.063-.383 1.605-1.133 4.245-.806 4.55-.423.304.391-.08 3.1-1.589 4.39-.232.2-.455.096-.35-.167.343-.855 1.102-2.75.742-3.305z"/>',
},
{
name: "Azure",
icon: '<path d="M5.483 21.3H14.3L8.544 8.947l5.963-6.407H6.626L.008 15.29zm3.694-2.092l7.913-9.153 2.422 5.724-10.335 3.43z"/>',
},
{
name: "Linux",
icon: '<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 0 0-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.368 1.884 1.43.868.065 1.157-.45 1.354-.962.088-.233.167-.468.157-.707.003-.07-.003-.14-.004-.208.138-.071.233-.14.36-.252.126-.113.243-.276.32-.415.158-.263.245-.525.206-.8-.04-.26-.132-.48-.263-.695a3.3 3.3 0 0 1-.12-.198c-.048-.097-.085-.155-.085-.155a1.047 1.047 0 0 0-.32-.397 1.58 1.58 0 0 0-.402-.22c-.168-.067-.371-.128-.576-.18a7.09 7.09 0 0 0-.207-.04c-.02-.005-.04-.01-.057-.014-.159-.037-.32-.068-.385-.073-.238-.017-.398.008-.553.066-.116.043-.236.114-.378.236-.158-.19-.248-.358-.36-.597-.112-.24-.226-.515-.408-.814-.182-.304-.408-.593-.706-.799-.295-.21-.637-.334-.974-.379a3.294 3.294 0 0 0-1.002.007c.118-.607.098-1.252-.142-1.873-.237-.616-.624-1.178-1.1-1.654-.476-.474-1.005-.875-1.482-1.243-.717-.552-1.32-1.06-1.55-1.675-.115-.305-.14-.63-.058-.958.082-.327.268-.647.572-.985l.005-.003c.394-.408.666-.75.864-1.05.196-.3.32-.56.386-.805.133-.484.196-.965.165-1.44-.029-.475-.137-.95-.308-1.43-.341-.961-.828-1.934-1.094-2.893-.266-.954-.292-1.87.155-2.694.242-.47.597-.85 1.036-1.126.44-.278.963-.456 1.556-.508.1-.008.2-.013.3-.013z"/>',
},
{
name: "Air-Gapped",
icon: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
},
];
const comparisonRows = [
{
label: "Data Privacy",
cloud: "Files uploaded to third-party servers",
self: "Files never leave your network",
},
{
label: "Compliance",
cloud: "Requires DPAs, vendor assessments",
self: "Compliant by architecture",
},
{
label: "Cost Model",
cloud: "Per-file or per-API-call pricing",
self: "Unlimited usage, fixed infrastructure cost",
},
{
label: "Internet Required",
cloud: "Always online",
self: "Works fully offline, air-gapped",
},
{
label: "Vendor Lock-in",
cloud: "Proprietary APIs, migration pain",
self: "Open source, standard formats, full API",
},
{
label: "AI Models",
cloud: "External API calls (OpenAI, etc.)",
self: "15 local models, no API keys",
},
{
label: "Customization",
cloud: "Limited to vendor offerings",
self: "Full source code access",
},
];
const checkSvg =
'<svg class="h-4 w-4 shrink-0 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const xSvg =
'<svg class="h-4 w-4 shrink-0 text-danger/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
---
<Base
title="Enterprise | SnapOtter"
description="Enterprise-grade self-hosted file processing. SAML SSO, audit logging, per-tool permissions, multi-tenancy. Deploy on your infrastructure."
canonical="https://snapotter.com/enterprise"
>
<Fragment slot="head">
<JsonLd data={[breadcrumbJsonLd, productJsonLd]} />
</Fragment>
<Navbar />
<main id="main">
<!-- ─── HERO (dark) ─── -->
<section class="section-dark bg-noise relative px-6 pt-32 pb-20 md:pt-44 md:pb-28">
<div class="relative mx-auto max-w-4xl text-center">
<!-- Breadcrumb -->
<nav class="animate-fade-up mb-8 text-sm text-dark-muted" aria-label="Breadcrumb">
<a href="/" class="transition-colors hover:text-dark-fg">Home</a>
<span class="mx-2">/</span>
<span class="text-dark-fg">Enterprise</span>
</nav>
<h1 class="animate-fade-up font-display text-4xl font-extrabold tracking-tight text-dark-fg sm:text-5xl md:text-6xl">
Enterprise-grade file processing.<br class="hidden sm:block" />
<span class="text-primary-light">On your infrastructure.</span>
</h1>
<p class="animate-fade-up mx-auto mt-6 max-w-2xl text-lg text-dark-muted md:text-xl" style="animation-delay: 0.1s;">
SAML SSO. Audit logging. Per-tool permissions. Multi-tenancy. All self-hosted, all open source at its core.
</p>
<div class="animate-fade-up mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row" style="animation-delay: 0.2s;">
<a href="/contact" class="btn-primary rounded-xl px-8 py-3.5 text-base font-semibold">
Book a Demo
</a>
<a href="/#pricing" class="rounded-xl border border-dark-border px-8 py-3.5 text-base font-semibold text-dark-fg transition-colors hover:border-primary hover:bg-primary/10">
View Pricing
</a>
</div>
</div>
</section>
<!-- ─── WHY SELF-HOSTED ─── -->
<section class="px-6 py-20 md:py-28">
<div class="reveal mx-auto mb-12 max-w-3xl text-center md:mb-16">
<h2 class="font-display text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
Why enterprises choose self-hosted.
</h2>
<p class="mt-4 text-lg text-muted md:text-xl">
Keep full control over your data, your costs, and your compliance posture.
</p>
</div>
<div class="mx-auto grid max-w-5xl gap-6 sm:grid-cols-3">
{whySelfHosted.map((card, i) => (
<div class:list={["reveal card-hover rounded-xl border border-border bg-surface p-6", `reveal-delay-${i + 1}`]}>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<svg class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={card.icon} />
</div>
<h3 class="mt-4 font-display text-lg font-bold">{card.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">{card.description}</p>
</div>
))}
</div>
</section>
<!-- ─── ENTERPRISE FEATURES (alternating rows) ─── -->
<section class="px-6 py-20 md:py-28">
<div class="reveal mx-auto mb-12 max-w-3xl text-center md:mb-16">
<h2 class="font-display text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
Every feature your team needs.
</h2>
<p class="mt-4 text-lg text-muted md:text-xl">
Security, compliance, and deployment capabilities built for enterprise requirements.
</p>
</div>
<div class="mx-auto max-w-6xl space-y-20 md:space-y-28">
<!-- Row 1: Identity & Access — text left, visual right -->
<div class="reveal grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
<div class="md:order-1">
<h3 class="font-display text-2xl font-bold tracking-tight sm:text-3xl">
Single sign-on for your entire team
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
SAML 2.0 and OIDC integration with any identity provider. Okta, Azure AD, Google Workspace, and more. SCIM provisioning for automated user lifecycle management. Enforce MFA across your organization.
</p>
</div>
<div class="md:order-2">
<div class="rounded-xl border border-border bg-surface p-6">
<span class="mb-4 block text-xs font-semibold uppercase tracking-wider text-primary">Supported Providers</span>
<div class="flex flex-wrap gap-2.5">
{identityProviders.map((provider) => (
<span class="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary-subtle px-3.5 py-1.5 text-sm font-medium text-primary-dark">
<svg class="h-3.5 w-3.5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{provider}
</span>
))}
</div>
<div class="mt-4 flex items-center gap-2 rounded-lg bg-primary-subtle/50 px-3 py-2">
<svg class="h-4 w-4 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span class="text-xs text-primary-dark">MFA enforced organization-wide</span>
</div>
</div>
</div>
</div>
<!-- Row 2: Permissions & Audit — visual left, text right -->
<div class="reveal grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
<div class="md:order-2">
<h3 class="font-display text-2xl font-bold tracking-tight sm:text-3xl">
Granular control. Complete visibility.
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
Role-based access control with admin, editor, user, and custom roles. Per-tool permissions let you restrict which tools each team can access. Every action is logged with full audit trails exportable for SOC 2, ISO 27001, and compliance reporting.
</p>
</div>
<div class="md:order-1">
<div class="rounded-xl border border-border bg-surface p-6 overflow-x-auto">
<span class="mb-4 block text-xs font-semibold uppercase tracking-wider text-primary">Permission Matrix</span>
<table class="w-full text-xs">
<thead>
<tr>
<th class="pb-2 text-start font-semibold text-muted">Role</th>
{permissionMatrix.features.map((f) => (
<th class="pb-2 text-center font-semibold text-muted">{f}</th>
))}
</tr>
</thead>
<tbody>
{permissionMatrix.roles.map((role, ri) => (
<tr class="border-t border-border">
<td class="py-2 font-medium">{role}</td>
{permissionMatrix.access[ri].map((allowed) => (
<td class="py-2 text-center">
{allowed
? <Fragment set:html={checkSvg.replace('class="', 'class="mx-auto ')} />
: <Fragment set:html={xSvg.replace('class="', 'class="mx-auto ')} />
}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<!-- Row 3: Storage & Infrastructure — text left, visual right -->
<div class="reveal grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
<div class="md:order-1">
<h3 class="font-display text-2xl font-bold tracking-tight sm:text-3xl">
Your storage. Your rules.
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
Use local storage or connect any S3-compatible backend: AWS S3, MinIO, Cloudflare R2, Google Cloud Storage. Multi-tenancy isolates teams with separate workspaces, configurations, and storage paths.
</p>
</div>
<div class="md:order-2">
<div class="grid grid-cols-2 gap-3">
{storageProviders.map((provider) => (
<div class="card-hover flex flex-col items-center gap-2.5 rounded-xl border border-border bg-surface p-5">
<svg class:list={["h-8 w-8", provider.color]} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
<span class="text-xs font-semibold">{provider.name}</span>
</div>
))}
</div>
</div>
</div>
<!-- Row 4: Deployment — visual left, text right -->
<div class="reveal grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
<div class="md:order-2">
<h3 class="font-display text-2xl font-bold tracking-tight sm:text-3xl">
Deploy anywhere. Scale as needed.
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
Single Docker container bundles everything: PostgreSQL, Redis, FFmpeg, LibreOffice, AI models. Deploy on Kubernetes with Helm charts, bare metal, or cloud VMs. ARM and x86 architectures. Air-gapped network support.
</p>
</div>
<div class="md:order-1">
<div class="grid grid-cols-3 gap-3">
{deploymentTargets.map((target) => (
<div class="card-hover flex flex-col items-center gap-2 rounded-xl border border-border bg-surface p-4">
{target.name === "Air-Gapped" ? (
<svg class="h-8 w-8 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" set:html={target.icon} />
) : (
<svg class="h-8 w-8 text-foreground" viewBox="0 0 24 24" fill="currentColor" set:html={target.icon} />
)}
<span class="text-xs font-medium">{target.name}</span>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<!-- ─── COMPARISON TABLE ─── -->
<section class="bg-background-alt px-6 py-20 md:py-28">
<div class="reveal mx-auto mb-12 max-w-3xl text-center md:mb-16">
<h2 class="font-display text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
SnapOtter vs. Cloud Processing
</h2>
<p class="mt-4 text-lg text-muted md:text-xl">
See why teams choose self-hosted file processing over cloud alternatives.
</p>
</div>
<div class="reveal mx-auto max-w-4xl overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="pb-4 text-start text-sm font-semibold text-muted"></th>
<th class="pb-4 text-start text-sm font-semibold text-muted">Cloud Processing</th>
<th class="pb-4 text-start text-sm font-semibold">
<span class="text-primary">SnapOtter</span> <span class="text-muted font-normal">(Self-Hosted)</span>
</th>
</tr>
</thead>
<tbody>
{comparisonRows.map((row) => (
<tr class="border-t border-border">
<td class="py-4 pe-6 font-medium">{row.label}</td>
<td class="py-4 pe-6">
<div class="flex items-start gap-2">
<Fragment set:html={xSvg} />
<span class="text-muted">{row.cloud}</span>
</div>
</td>
<td class="py-4">
<div class="flex items-start gap-2">
<Fragment set:html={checkSvg} />
<span>{row.self}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<!-- ─── CTA (dark) ─── -->
<section class="section-dark bg-noise relative px-6 py-20 md:py-28">
<div class="reveal relative mx-auto max-w-3xl text-center">
<h2 class="font-display text-3xl font-bold tracking-tight text-dark-fg sm:text-4xl md:text-5xl">
Ready to deploy?
</h2>
<p class="mt-4 text-lg text-dark-muted md:text-xl">
Talk to our team about enterprise licensing, deployment assistance, and custom integrations.
</p>
<div class="mt-10">
<a href="/contact" class="btn-primary inline-block rounded-xl px-10 py-4 text-base font-semibold">
Book a Demo
</a>
</div>
<p class="mt-6 text-sm text-dark-muted">
Or email us at <a href="mailto:contact@snapotter.com" class="font-medium text-primary-light hover:underline">contact@snapotter.com</a>
</p>
</div>
</section>
</main>
<Footer />
</Base>
+124
View File
@@ -0,0 +1,124 @@
---
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import Base from "@/layouts/Base.astro";
const faqs = [
{
question: "Are my files safe and private?",
answer:
"All processing happens on your own server. Your files are never sent to any external service. SnapOtter runs entirely on your infrastructure.",
},
{
question: "Is SnapOtter really free?",
answer:
"Yes. SnapOtter is open source under AGPL-3.0 with no hidden fees, no subscription plans, no usage limits, and no premium-only features. A commercial license is available for organizations that need proprietary use.",
},
{
question: "Do I need an internet connection?",
answer:
"No. SnapOtter runs fully offline once deployed. No external API calls are made. It works in completely air-gapped environments.",
},
{
question: "Are there any file size or usage limitations?",
answer:
"No artificial limits. You can process as many files as you want, at any size. The only constraint is your server's hardware resources.",
},
{
question: "What AI features are included?",
answer:
"SnapOtter includes 15 local AI models for background removal, upscaling, OCR, face detection, colorization, noise removal, photo restoration, and more. All models run on your hardware.",
},
{
question: "Does SnapOtter collect any analytics?",
answer:
"Analytics is completely optional and disabled by default. If you choose to enable it, SnapOtter collects anonymous usage data and error logs to help improve the software. No personal data or file content is ever collected. You have full control over this in the settings and can disable it at any time.",
},
{
question: "Can I use SnapOtter in my company?",
answer:
"Yes. Under AGPL-3.0, you can use it freely as long as you comply with the license terms. For proprietary or closed-source use, a commercial license is available.",
},
{
question: "How do I update SnapOtter?",
answer:
"Pull the latest Docker image and restart the container. Your data persists in the mounted volume.",
},
];
const faqJsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: { "@type": "Answer", text: faq.answer },
})),
};
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
{ "@type": "ListItem", position: 2, name: "FAQ", item: "https://snapotter.com/faq" },
],
};
---
<Base
title="FAQ | SnapOtter"
description="Frequently asked questions about SnapOtter, the self-hosted file processing suite."
canonical="https://snapotter.com/faq"
>
<Fragment slot="head">
<JsonLd data={[faqJsonLd, breadcrumbJsonLd]} />
</Fragment>
<Navbar />
<main id="main" class="px-6 pt-32 pb-20 md:pt-40 md:pb-28">
<div class="mx-auto max-w-3xl">
<h1 class="reveal font-display text-4xl font-bold tracking-tight md:text-5xl">
Frequently Asked Questions
</h1>
<p class="reveal mt-4 text-lg text-muted">
Everything you need to know about SnapOtter.
</p>
<div class="mt-12 divide-y divide-border">
{faqs.map((faq) => (
<details class="group reveal">
<summary class="flex cursor-pointer items-center justify-between py-5 text-base font-medium">
{faq.question}
<svg
class="h-4 w-4 shrink-0 text-muted transition-transform duration-200 group-open:rotate-180"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<p class="pb-5 text-sm leading-relaxed text-muted">{faq.answer}</p>
</details>
))}
</div>
<div class="reveal mt-16 rounded-xl border border-border bg-surface p-8 text-center">
<h2 class="font-display text-xl font-semibold">Still have questions?</h2>
<p class="mt-2 text-sm text-muted">Reach out via email or join our community.</p>
<div class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
<a href="/contact" class="btn-primary rounded-lg px-6 py-2.5 text-sm font-semibold">Contact Us</a>
<a href="https://discord.gg/hr3s7HPUsr" target="_blank" rel="noopener noreferrer" class="rounded-lg border border-border px-6 py-2.5 text-sm font-semibold transition-colors hover:bg-background-alt">Join Discord</a>
</div>
</div>
</div>
</main>
<Footer />
</Base>
+122
View File
@@ -0,0 +1,122 @@
---
import { TOOLS } from "@snapotter/shared";
import EnterpriseSection from "@/components/EnterpriseSection.astro";
import FeatureHighlights from "@/components/FeatureHighlights.astro";
import Footer from "@/components/Footer.astro";
import Hero from "@/components/Hero.astro";
import HowItWorks from "@/components/HowItWorks.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import OpenSource from "@/components/OpenSource.astro";
import Pricing from "@/components/Pricing.astro";
import ToolGrid from "@/components/ToolGrid.astro";
import TrustSignals from "@/components/TrustSignals.astro";
import Base from "@/layouts/Base.astro";
const toolCount = TOOLS.length;
const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "SnapOtter",
url: "https://snapotter.com",
description: `Self-hosted file processing suite with ${toolCount} tools across 5 modalities.`,
publisher: {
"@type": "Organization",
name: "SnapOtter",
logo: {
"@type": "ImageObject",
url: "https://snapotter.com/logo.png",
},
sameAs: [
"https://github.com/snapotter-hq/snapotter",
"https://x.com/SnapOtterHQ",
"https://discord.gg/hr3s7HPUsr",
"https://hub.docker.com/r/snapotter/snapotter",
],
},
};
const softwareSchema = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "SnapOtter",
applicationCategory: "UtilitiesApplication",
operatingSystem: "Docker, Linux, macOS, Windows",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
license: "https://www.gnu.org/licenses/agpl-3.0.en.html",
downloadUrl: "https://hub.docker.com/r/snapotter/snapotter",
featureList: [
`${toolCount} file processing tools`,
"5 modalities: image, video, audio, document, data",
"Local AI models for background removal, OCR, upscaling, transcription",
"Pipeline automation and batch processing",
"Full REST API with OpenAPI documentation",
"Self-hosted, fully offline, air-gapped capable",
"SAML SSO, SCIM, MFA, audit logging (Enterprise)",
"21 languages supported",
],
};
const navSchema = {
"@context": "https://schema.org",
"@type": "ItemList",
itemListElement: [
{
"@type": "SiteNavigationElement",
name: "Documentation",
url: "https://docs.snapotter.com",
description: "Setup guides, API reference, and tool documentation",
},
{
"@type": "SiteNavigationElement",
name: "FAQ",
url: "https://snapotter.com/faq",
description: "Frequently asked questions",
},
{
"@type": "SiteNavigationElement",
name: "GitHub",
url: "https://github.com/snapotter-hq/snapotter",
description: "Source code and issue tracker",
},
{
"@type": "SiteNavigationElement",
name: "Contact",
url: "https://snapotter.com/contact",
description: "Book a demo or get enterprise licensing",
},
{
"@type": "SiteNavigationElement",
name: "Discord",
url: "https://discord.gg/hr3s7HPUsr",
description: "Community support and discussion",
},
],
};
---
<Base canonical="https://snapotter.com">
<Fragment slot="head">
<JsonLd data={[websiteSchema, softwareSchema, navSchema]} />
</Fragment>
<Navbar />
<main id="main">
<Hero />
<TrustSignals />
<ToolGrid />
<FeatureHighlights />
<EnterpriseSection />
<HowItWorks />
<Pricing />
<OpenSource />
</main>
<Footer />
</Base>
+143
View File
@@ -0,0 +1,143 @@
---
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import Base from "@/layouts/Base.astro";
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",
},
],
};
---
<Base
title="Privacy Policy | SnapOtter"
description="SnapOtter is self-hosted software. We do not process, store, or have access to your images or data. Learn about our privacy practices."
canonical="https://snapotter.com/privacy"
>
<Fragment slot="head">
<JsonLd data={breadcrumbJsonLd} />
</Fragment>
<Navbar />
<main id="main" class="px-6 pt-32 pb-20 md:pt-40 md:pb-28">
<div class="mx-auto max-w-3xl">
<div class="reveal text-center">
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">Privacy Policy</h1>
<p class="mt-6 text-lg text-muted">Last updated: May 18, 2026</p>
</div>
<div class="mt-16 space-y-12 text-sm leading-relaxed text-muted">
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Data Controller</h2>
<p class="mt-3">
SnapOtter is a product of Chocolate Wafers General Trading Pte. Ltd. (UEN
202527149C), registered in Singapore. References to &quot;we&quot;,
&quot;us&quot;, or &quot;our&quot; in this policy refer to Chocolate Wafers
General Trading Pte. Ltd.
</p>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Overview</h2>
<p class="mt-3">
SnapOtter is self-hosted software. We do not process, store, or have access to
your images or data. This privacy policy covers the snapotter.com website and
optional analytics within the software.
</p>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Website (snapotter.com)</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>We do not use tracking cookies.</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.
</li>
</ul>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Self-Hosted Software</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>All image processing happens entirely on your server.</li>
<li>No data is sent to SnapOtter or any third party.</li>
<li>There is no telemetry by default.</li>
</ul>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Optional Analytics</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>Analytics is disabled by default.</li>
<li>
When enabled, it collects anonymous usage data (feature usage, error logs) to
help improve the software.
</li>
<li>No personal data, image content, or file names are collected.</li>
<li>You can disable analytics at any time in settings.</li>
<li>Analytics data is processed by PostHog.</li>
</ul>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Contact Form</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>
When you submit the contact form, your name, email, company, and message are
sent to us via Formspree.
</li>
<li>We use this information only to respond to your inquiry.</li>
<li>We do not sell or share your contact information.</li>
</ul>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Open Source</h2>
<p class="mt-3">
SnapOtter is open source. You can inspect the entire codebase to verify our
privacy practices.
</p>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Changes</h2>
<p class="mt-3">
We may update this policy. Changes will be posted on this page.
</p>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Contact</h2>
<p class="mt-3">
If you have questions about this privacy policy, contact us at
<a
href="mailto:contact@snapotter.com"
class="text-primary font-medium hover:underline underline-offset-4"
>
contact@snapotter.com
</a>.
</p>
</section>
</div>
</div>
</main>
<Footer />
</Base>
+142
View File
@@ -0,0 +1,142 @@
---
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import Base from "@/layouts/Base.astro";
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",
},
],
};
---
<Base
title="Terms and Conditions | SnapOtter"
description="Terms governing your use of the SnapOtter website and self-hosted image processing software."
canonical="https://snapotter.com/terms"
>
<Fragment slot="head">
<JsonLd data={breadcrumbJsonLd} />
</Fragment>
<Navbar />
<main id="main" class="px-6 pt-32 pb-20 md:pt-40 md:pb-28">
<div class="mx-auto max-w-3xl">
<div class="reveal text-center">
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">
Terms and Conditions
</h1>
<p class="mt-6 text-lg text-muted">Last updated: April 24, 2026</p>
</div>
<div class="mt-16 space-y-12 text-sm leading-relaxed text-muted">
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Who We Are</h2>
<p class="mt-3">
SnapOtter is a product of Chocolate Wafers General Trading Pte. Ltd. (UEN
202527149C), an exempt private company registered in Singapore. References to
&quot;we&quot;, &quot;us&quot;, or &quot;our&quot; in these terms refer to
Chocolate Wafers General Trading Pte. Ltd.
</p>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Overview</h2>
<p class="mt-3">
These terms govern your use of the SnapOtter website and software.
</p>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Software License</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>SnapOtter is licensed under AGPL-3.0.</li>
<li>
You may use, modify, and distribute the software under the terms of this
license.
</li>
<li>
For proprietary or commercial use without AGPL obligations, a commercial license
is available.
</li>
<li>
Contact us at
<a
href="mailto:contact@snapotter.com"
class="text-primary font-medium hover:underline underline-offset-4"
>
contact@snapotter.com
</a>
for commercial licensing terms.
</li>
</ul>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Website Use</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>The snapotter.com website is provided for informational purposes.</li>
<li>You may not use the website for any unlawful purpose.</li>
</ul>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Self-Hosted Software</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>You are responsible for your own deployment, data, and security.</li>
<li>SnapOtter is provided &quot;as is&quot; without warranty of any kind.</li>
<li>We are not liable for any damages arising from your use of the software.</li>
</ul>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Intellectual Property</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>SnapOtter, the SnapOtter logo, and related marks are our property.</li>
<li>The software source code is available under AGPL-3.0.</li>
</ul>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Limitation of Liability</h2>
<p class="mt-3">
To the maximum extent permitted by law, we are not liable for any indirect,
incidental, or consequential damages.
</p>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Changes</h2>
<p class="mt-3">
We may update these terms. Continued use constitutes acceptance.
</p>
</section>
<section class="reveal">
<h2 class="text-lg font-semibold text-foreground">Contact</h2>
<p class="mt-3">
If you have questions about these terms, contact us at
<a
href="mailto:contact@snapotter.com"
class="text-primary font-medium hover:underline underline-offset-4"
>
contact@snapotter.com
</a>.
</p>
</section>
</div>
</div>
</main>
<Footer />
</Base>
@@ -0,0 +1,406 @@
---
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import { TOOL_SEO } from "@/data/tool-seo";
import Base from "@/layouts/Base.astro";
export function getStaticPaths() {
return TOOLS.map((tool) => ({ params: { slug: tool.id }, props: { tool } }));
}
const { tool } = Astro.props;
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
const category = categoryMap.get(tool.category);
const seo = TOOL_SEO[tool.id];
const related = TOOLS.filter((t) => t.category === tool.category && t.id !== tool.id).slice(0, 4);
const catColor = category?.color ?? "#737373";
const isAiTool = (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(tool.id);
const toolCount = TOOLS.length;
const title = seo ? `${seo.searchTitle} | SnapOtter` : `${tool.name} | SnapOtter`;
const description = seo?.longDescription ?? tool.description;
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/tools" },
{
"@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,
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 modalityLabel =
tool.modality === "image"
? "image"
: tool.modality === "video"
? "video"
: tool.modality === "audio"
? "audio"
: "file";
const uploadStep =
tool.modality === "image"
? "Drag and drop your image file or click to browse. Supports JPEG, PNG, WebP, AVIF, TIFF, HEIC, and RAW formats."
: tool.modality === "video"
? "Drag and drop your video file or click to browse. Supports MP4, MOV, AVI, MKV, WebM, and more."
: tool.modality === "audio"
? "Drag and drop your audio file or click to browse. Supports MP3, WAV, FLAC, AAC, OGG, and more."
: "Drag and drop your file or click to browse. Supports a wide range of input formats.";
const howToJsonLd = {
"@context": "https://schema.org",
"@type": "HowTo",
name: `How to use ${tool.name} in SnapOtter`,
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 ${modalityLabel}`,
text: uploadStep,
},
{
"@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;
const allJsonLd = [
breadcrumbJsonLd,
softwareJsonLd,
howToJsonLd,
...(faqJsonLd ? [faqJsonLd] : []),
];
---
<Base
title={title}
description={description}
canonical={`https://snapotter.com/tools/${tool.id}`}
>
<Fragment slot="head">
<JsonLd data={allJsonLd} />
</Fragment>
<Navbar />
<main id="main">
<!-- Breadcrumb -->
<nav class="mx-auto max-w-4xl px-6 pt-28 md:pt-36" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-muted">
<li>
<a href="/" class="transition-colors hover:text-foreground">Home</a>
</li>
<li aria-hidden="true">/</li>
<li>
<a href="/tools" class="transition-colors hover:text-foreground">Tools</a>
</li>
<li aria-hidden="true">/</li>
<li class="font-medium text-foreground">{tool.name}</li>
</ol>
</nav>
<!-- Hero -->
<section class="mx-auto max-w-4xl px-6 pt-8 pb-16">
<div class="reveal">
<div class="mb-6 flex items-center gap-3">
<div
class="flex h-14 w-14 items-center justify-center rounded-2xl"
style={`background-color: ${catColor}14`}
>
<svg
class="h-7 w-7"
viewBox="0 0 24 24"
fill="none"
stroke={catColor}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="5" y="5" rx="2" />
</svg>
</div>
<span
class="rounded-full px-3 py-1 text-xs font-semibold"
style={`background-color: ${catColor}14; color: ${catColor}`}
>
{category?.name}
</span>
{isAiTool && (
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
AI-Powered
</span>
)}
</div>
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">
{tool.name}
</h1>
<p class="mt-4 text-lg leading-relaxed text-muted md:text-xl">
{description}
</p>
<div class="mt-8 flex flex-wrap gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
<svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12.5V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h7.5" />
<path d="m22 17-5 5-5-5" />
<path d="M17 22V12" />
</svg>
Deploy with Docker
</a>
<a
href="https://github.com/snapotter-hq/snapotter"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
<svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
View Source
</a>
</div>
</div>
</section>
<!-- Features -->
{seo?.features && seo.features.length > 0 && (
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<div class="mb-6 flex items-center gap-2">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke={catColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
Features
</h2>
</div>
<ul class="grid gap-3 sm:grid-cols-2">
{seo.features.map((feature) => (
<li class="flex gap-3">
<svg class="mt-0.5 h-[18px] w-[18px] shrink-0" viewBox="0 0 24 24" fill="none" stroke={catColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span class="text-sm leading-relaxed">{feature}</span>
</li>
))}
</ul>
</div>
</div>
</section>
)}
<!-- Use Cases -->
{seo?.useCases && seo.useCases.length > 0 && (
<section class="py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
What you can do
</h2>
<ul class="mt-8 grid gap-4 sm:grid-cols-2">
{seo.useCases.map((useCase) => (
<li class="flex gap-3">
<svg class="mt-1 h-4 w-4 shrink-0 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
<span class="text-sm leading-relaxed text-muted">{useCase}</span>
</li>
))}
</ul>
</div>
</div>
</section>
)}
<!-- Self-Hosted Callout -->
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<div class="flex items-start gap-4">
<svg class="mt-1 h-6 w-6 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>
<div>
<h2 class="font-display text-xl font-bold">
{isAiTool
? "AI that runs on your hardware. No cloud APIs, no usage limits."
: "Self-hosted. Your files never leave your network."}
</h2>
<p class="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 files are processed locally with no data sent to external APIs. No per-file 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. Files processed with ${tool.name} are never uploaded to third-party servers. Deploy a single Docker container and process files with full privacy, no watermarks, and no usage limits. Open source under AGPL-3.0.`}
</p>
</div>
</div>
</div>
</div>
</section>
<!-- FAQs -->
{seo?.faqs && seo.faqs.length > 0 && (
<section class="py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
Frequently asked questions
</h2>
<dl class="mt-8 space-y-6">
{seo.faqs.map((faq) => (
<div class="rounded-xl border border-border bg-surface p-6">
<dt class="text-sm font-semibold">{faq.q}</dt>
<dd class="mt-3 text-sm leading-relaxed text-muted">{faq.a}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
)}
<!-- Related Tools -->
{related.length > 0 && (
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight">
More {category?.name} tools
</h2>
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{related.map((t) => {
const relCat = categoryMap.get(t.category);
const relColor = relCat?.color ?? "#737373";
return (
<a
href={`/tools/${t.id}`}
class="group flex flex-col rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
>
<svg
class="h-6 w-6"
viewBox="0 0 24 24"
fill="none"
stroke={relColor}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="5" y="5" rx="2" />
</svg>
<span class="mt-3 text-sm font-bold">{t.name}</span>
<p class="mt-1.5 text-xs leading-relaxed text-muted">{t.description}</p>
<span class="mt-auto inline-flex items-center gap-1 pt-4 text-xs font-medium text-primary transition-all group-hover:gap-2">
Learn more
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</span>
</a>
);
})}
</div>
</div>
</div>
</section>
)}
<!-- Bottom CTA -->
<section class="py-20">
<div class="mx-auto max-w-4xl px-6 text-center">
<div class="reveal">
<h2 class="font-display text-3xl font-bold tracking-tight">
Ready to try {tool.name}?
</h2>
<p class="mx-auto mt-4 max-w-lg text-muted">
Deploy SnapOtter in under a minute. All {toolCount} tools included. Open source and free forever.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
Get Started
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</a>
<a
href="/#pricing"
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
View Pricing
</a>
</div>
</div>
</div>
</section>
</main>
<Footer />
</Base>
+217
View File
@@ -0,0 +1,217 @@
---
import { CATEGORIES, TOOLS } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import Base from "@/layouts/Base.astro";
function renderIcon(iconName: string): string {
const iconData = (lucideIcons as Record<string, unknown>)[iconName];
if (!iconData || !Array.isArray(iconData))
return '<rect width="14" height="14" x="5" y="5" rx="2" />';
return iconData
.map(([tag, attrs]: [string, Record<string, string>]) => {
const attrStr = Object.entries(attrs)
.map(([k, v]) => `${k}="${v}"`)
.join(" ");
return `<${tag} ${attrStr}/>`;
})
.join("");
}
const groups = CATEGORIES.map((cat) => ({
category: cat,
tools: TOOLS.filter((t) => t.category === cat.id),
})).filter((group) => group.tools.length > 0);
const toolCount = TOOLS.length;
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/tools" },
],
};
const collectionJsonLd = {
"@context": "https://schema.org",
"@type": "CollectionPage",
name: "SnapOtter File Processing Tools",
description: `${toolCount} self-hosted file processing tools with local AI. Resize, compress, convert, remove backgrounds, upscale, OCR, and more.`,
url: "https://snapotter.com/tools",
isPartOf: {
"@type": "WebSite",
name: "SnapOtter",
url: "https://snapotter.com",
},
numberOfItems: toolCount,
mainEntity: {
"@type": "ItemList",
numberOfItems: toolCount,
itemListElement: TOOLS.map((tool, i) => ({
"@type": "ListItem",
position: i + 1,
name: tool.name,
url: `https://snapotter.com/tools/${tool.id}`,
})),
},
};
---
<Base
title={`All ${toolCount} File Processing Tools | SnapOtter`}
description={`Browse all ${toolCount} self-hosted file processing tools: resize, compress, convert, remove backgrounds, upscale with AI, add watermarks, generate QR codes, and more. Free, open source, runs on your hardware.`}
canonical="https://snapotter.com/tools"
>
<Fragment slot="head">
<JsonLd data={[breadcrumbJsonLd, collectionJsonLd]} />
</Fragment>
<Navbar />
<main id="main">
<!-- Breadcrumb -->
<nav class="mx-auto max-w-5xl px-6 pt-28 md:pt-36" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-muted">
<li>
<a href="/" class="transition-colors hover:text-foreground">Home</a>
</li>
<li aria-hidden="true">/</li>
<li class="font-medium text-foreground">Tools</li>
</ol>
</nav>
<!-- Hero -->
<section class="mx-auto max-w-5xl px-6 pt-8 pb-16">
<div class="reveal">
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">
All {toolCount} tools
</h1>
<p class="mt-4 max-w-2xl text-lg leading-relaxed text-muted md:text-xl">
Every tool runs 100% on your hardware. No cloud uploads, no usage limits, no
watermarks. Deploy once with Docker and use them all.
</p>
</div>
</section>
<!-- Tool categories -->
{groups.map((group) => (
<section class="border-t border-border py-12 even:bg-background-alt">
<div class="mx-auto max-w-5xl px-6">
<div class="reveal">
<div class="mb-8 flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl"
style={`background-color: ${group.category.color}14`}
>
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke={group.category.color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
{group.category.icon === "Layers" && <><path d="M12 2 2 7l10 5 10-5-10-5Z" /><path d="m2 17 10 5 10-5" /><path d="m2 12 10 5 10-5" /></>}
{group.category.icon === "Zap" && <><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></>}
{group.category.icon === "SlidersHorizontal" && <><line x1="21" x2="14" y1="4" y2="4" /><line x1="10" x2="3" y1="4" y2="4" /><line x1="21" x2="12" y1="12" y2="12" /><line x1="8" x2="3" y1="12" y2="12" /><line x1="21" x2="16" y1="20" y2="20" /><line x1="12" x2="3" y1="20" y2="20" /><line x1="14" x2="14" y1="2" y2="6" /><line x1="8" x2="8" y1="10" y2="14" /><line x1="16" x2="16" y1="18" y2="22" /></>}
{group.category.icon === "Stamp" && <><path d="M5 22h14" /><path d="M19.27 13.73A2.5 2.5 0 0 0 17.5 13h-11A2.5 2.5 0 0 0 4 15.5V17a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-1.5c0-.66-.26-1.3-.73-1.77Z" /><path d="M14 13V8.5C14 7 15 7 15 5a3 3 0 0 0-6 0c0 2 1 2 1 3.5V13" /></>}
{group.category.icon === "Wrench" && <><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></>}
{group.category.icon === "LayoutGrid" && <><rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" /><rect width="7" height="7" x="3" y="14" rx="1" /></>}
{group.category.icon === "FileType" && <><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M9 13v-1h6v1" /><path d="M12 12v6" /><path d="M11 18h2" /></>}
{group.category.icon === "Video" && <><path d="m22 8-6 4 6 4V8Z" /><rect width="14" height="12" x="2" y="6" rx="2" ry="2" /></>}
{group.category.icon === "AudioLines" && <><path d="M2 10v3" /><path d="M6 6v11" /><path d="M10 3v18" /><path d="M14 8v7" /><path d="M18 5v13" /><path d="M22 10v3" /></>}
{group.category.icon === "FileText" && <><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" /></>}
{group.category.icon === "Table" && <><path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /></>}
{group.category.icon === "Sparkles" && <><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" /><path d="M5 3v4" /><path d="M19 17v4" /><path d="M3 5h4" /><path d="M17 19h4" /></>}
</svg>
</div>
<h2 class="font-display text-2xl font-bold">
{group.category.name}
</h2>
<span class="text-sm text-muted">
{group.tools.length} {group.tools.length === 1 ? "tool" : "tools"}
</span>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{group.tools.map((tool) => (
<a
href={`/tools/${tool.id}`}
class="group flex items-start gap-4 rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style={`background-color: ${group.category.color}14`}
>
<svg
class="h-[18px] w-[18px]"
viewBox="0 0 24 24"
fill="none"
stroke={group.category.color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
set:html={renderIcon(tool.icon)}
/>
</div>
<div class="min-w-0">
<span class="text-sm font-bold">{tool.name}</span>
<p class="mt-1 line-clamp-2 text-xs leading-relaxed text-muted">
{tool.description}
</p>
<span class="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary transition-all group-hover:gap-2">
Learn more
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</span>
</div>
</a>
))}
</div>
</div>
</div>
</section>
))}
<!-- Bottom CTA -->
<section class="border-t border-border py-20">
<div class="mx-auto max-w-5xl px-6 text-center">
<div class="reveal">
<h2 class="font-display text-3xl font-bold tracking-tight">
Deploy all {toolCount} tools with one command
</h2>
<p class="mx-auto mt-4 max-w-lg text-muted">
A single Docker container. No external services. Open source and free forever.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
Get Started
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</a>
<a
href="/#pricing"
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
View Pricing
</a>
</div>
</div>
</div>
</section>
</main>
<Footer />
</Base>