feat(landing): enterprise-focused hero redesign and section polish (#239)

Reframe the landing page around the self-hosted enterprise buyer.

Hero
- Rotating-modality headline with a fixed "Nothing leaves your network"
  promise (pure CSS, no client JS) and an enterprise subtitle
- Instant, modality-aware tool search (multi-word) linking to per-tool pages
- Modality cards with live counts; stat strip (150+ tools, GitHub stars,
  100K+ image pulls, 20+ languages); 7 enterprise trust badges
- Removed the Docker terminal and the hero CTAs

Sections
- "Built for enterprise deployment" redesigned as a security control plane
  (category-tagged 4-up grid, audit-ready frameworks row)
- "Built for regulated environments" polished with numbered kickers and an
  accurate Docker Compose deployment description
- ToolGrid is browse-only now (dropped its redundant search) with a clearer heading

Fixes and infra
- Per-tool pages render the real tool icon instead of a placeholder square
- Navbar GitHub stars fetch live client-side with a 1-hour localStorage cache
- Stats auto-update from the GitHub and Docker Hub APIs at build time
- Enterprise tab title and meta description
This commit is contained in:
SnapOtter
2026-06-15 21:55:00 +08:00
committed by GitHub
parent b76dc68682
commit f622a4f691
12 changed files with 663 additions and 258 deletions
@@ -0,0 +1,85 @@
---
import { TOOLS } from "@snapotter/shared";
import * as lucideIcons from "lucide";
function renderIcon(iconName: string): string {
const iconData = (lucideIcons as Record<string, unknown>)[iconName];
if (!iconData || !Array.isArray(iconData)) return "";
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 MODALITIES = [
{
id: "image",
label: "Image Tools",
blurb: "Resize, convert, and enhance",
icon: "Image",
color: "#E07832",
},
{
id: "video",
label: "Video Tools",
blurb: "Trim, convert, and caption",
icon: "Video",
color: "#BE4A3C",
},
{
id: "audio",
label: "Audio Tools",
blurb: "Convert, trim, and transcribe",
icon: "Music",
color: "#2C7A75",
},
{
id: "document",
label: "PDF & Documents",
blurb: "Merge, split, and convert",
icon: "FileText",
color: "#44568C",
},
{
id: "file",
label: "Data & Files",
blurb: "Convert and transform",
icon: "Database",
color: "#5C8642",
},
];
const cards = MODALITIES.map((m) => {
const tools = TOOLS.filter((t) => t.modality === m.id);
return { ...m, count: tools.length };
});
---
<div class="animate-fade-up mt-10" style="animation-delay: 0.22s;">
<div class="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{cards.map((c) => (
<a
href="/tools"
class="group flex flex-col rounded-2xl p-4 text-start text-white no-underline shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
style={`background-color: ${c.color}`}
>
<div class="flex items-center justify-between">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={renderIcon(c.icon)} />
</span>
<span class="rounded-full bg-white/20 px-2.5 py-0.5 text-xs font-semibold">{c.count} tools</span>
</div>
<div class="mt-4 flex items-center gap-1.5">
<p class="font-display text-base font-bold leading-tight">{c.label}</p>
<svg class="h-4 w-4 -translate-x-1 opacity-0 transition-all group-hover:translate-x-0 group-hover:opacity-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</div>
<p class="mt-1 text-xs text-white/80">{c.blurb}</p>
</a>
))}
</div>
</div>
@@ -1,79 +1,133 @@
---
const features = [
{
tag: "Identity",
title: "SAML SSO",
description: "Okta, Azure AD, Google Workspace, or any SAML 2.0 / OIDC identity provider.",
icon: '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/>',
},
{
tag: "Identity",
title: "SCIM Provisioning",
description:
"Automated user provisioning and deprovisioning synced from your identity provider.",
icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
},
{
tag: "Access",
title: "Multi-Factor Auth",
description: "Enforce TOTP-based MFA across your organization. Protect every account.",
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
},
{
title: "Multi-Tenancy",
description: "Isolated workspaces per team or department with separate configurations.",
icon: '<rect x="4" y="2" width="16" height="20" rx="2" ry="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M8 10h.01"/><path d="M16 10h.01"/><path d="M8 14h.01"/><path d="M16 14h.01"/>',
},
{
tag: "Access",
title: "Per-Tool Permissions",
description: "Granular role-based access control. Admin, editor, user, and custom roles.",
icon: '<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>',
},
{
tag: "Isolation",
title: "Multi-Tenancy",
description: "Isolated workspaces per team or department with separate configurations.",
icon: '<rect x="4" y="2" width="16" height="20" rx="2" ry="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M8 10h.01"/><path d="M16 10h.01"/><path d="M8 14h.01"/><path d="M16 14h.01"/>',
},
{
tag: "Compliance",
title: "Audit Export",
description:
"Every action logged. Export audit trails for SOC 2, ISO 27001, and compliance reporting.",
"Every action logged. Export immutable audit trails for SOC 2, ISO 27001, and compliance reporting.",
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"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
},
{
tag: "Infrastructure",
title: "S3-Compatible Storage",
description: "AWS S3, MinIO, Cloudflare R2 for persistent, scalable file storage.",
description: "AWS S3, MinIO, or Cloudflare R2 for persistent, scalable file storage.",
icon: '<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"/>',
},
{
tag: "Integration",
title: "Webhooks",
description: "Real-time notifications when processing completes. Integrate with any workflow.",
description:
"Real-time notifications when processing completes. Integrate with any internal workflow.",
icon: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
},
];
const frameworks = ["SOC 2", "ISO 27001", "HIPAA", "GDPR"];
---
<section class="section-dark bg-noise relative px-6 py-24 md:py-32" id="enterprise">
<div class="reveal 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">
Built for enterprise deployment.
</h2>
<p class="mt-4 text-lg text-dark-muted md:text-xl">
Every security and compliance feature your team needs, running on your infrastructure.
</p>
<section class="section-dark bg-noise relative overflow-hidden px-6 py-24 md:py-32" id="enterprise">
<!-- Atmosphere: warm security glow -->
<div
class="pointer-events-none absolute inset-x-0 top-0 mx-auto h-96 max-w-3xl rounded-full bg-primary/10 blur-[120px]"
aria-hidden="true"
>
</div>
<div class="mx-auto mt-14 grid max-w-5xl gap-4 sm:grid-cols-2">
{features.map((feature, i) => (
<div class:list={["reveal card-dark border border-dark-border rounded-xl p-6", `reveal-delay-${Math.min(i + 1, 4)}`]}>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
<svg class="h-5 w-5 text-primary-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={feature.icon} />
<div class="relative mx-auto max-w-6xl">
<!-- Header -->
<div class="reveal mx-auto max-w-3xl text-center">
<span class="inline-flex items-center gap-2 rounded-full border border-primary/30 bg-primary/10 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.2em] text-primary-light">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
Enterprise-grade security
</span>
<h2 class="mt-6 font-display text-3xl font-bold tracking-tight text-dark-fg sm:text-4xl md:text-5xl">
Built for enterprise deployment.
</h2>
<p class="mt-4 text-lg leading-relaxed text-dark-muted md:text-xl">
Every identity, access, and compliance control your security team expects, running entirely on infrastructure you own.
</p>
</div>
<!-- Control grid -->
<div class="mt-16 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{features.map((feature, i) => (
<div
class:list={[
"reveal card-dark group relative flex flex-col rounded-xl border border-dark-border p-5 transition-all duration-300 hover:-translate-y-1 hover:border-primary/40",
`reveal-delay-${Math.min(i + 1, 4)}`,
]}
>
<div class="flex items-center justify-between">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/15 ring-1 ring-inset ring-primary/20 transition-colors group-hover:bg-primary/25">
<svg
class="h-5 w-5 text-primary-light"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
set:html={feature.icon}
/>
</div>
<span class="font-mono text-[10px] uppercase tracking-[0.15em] text-primary/60">{feature.tag}</span>
</div>
<h3 class="mt-4 font-display text-base font-bold text-dark-fg">{feature.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-dark-muted">{feature.description}</p>
</div>
<h3 class="mt-4 font-display text-base font-bold text-dark-fg">{feature.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-dark-muted">{feature.description}</p>
</div>
))}
</div>
))}
</div>
<div class="reveal mt-14 text-center">
<a
href="/contact"
class="btn-primary inline-block rounded-xl px-8 py-3 text-base font-semibold"
>
Book a Demo
</a>
<p class="mt-4 text-sm text-dark-muted">Talk to our team about enterprise licensing and deployment.</p>
<!-- Compliance frameworks -->
<div class="reveal mt-12 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<span class="text-xs font-medium uppercase tracking-[0.15em] text-dark-muted">Audit-ready for</span>
<div class="flex flex-wrap items-center justify-center gap-2.5">
{frameworks.map((fw) => (
<span class="inline-flex items-center gap-1.5 rounded-md border border-dark-border bg-dark-surface px-3 py-1.5 text-xs font-semibold text-dark-fg">
<svg class="h-3.5 w-3.5 text-primary-light" 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>
{fw}
</span>
))}
</div>
</div>
<!-- CTA -->
<div class="reveal mt-14 text-center">
<a href="/contact" class="btn-primary inline-block rounded-xl px-8 py-3 text-base font-semibold">
Book a Demo
</a>
<p class="mt-4 text-sm text-dark-muted">Talk to our team about enterprise licensing and deployment.</p>
</div>
</div>
</section>
@@ -1,19 +1,57 @@
---
import SectionHeading from "./SectionHeading.astro";
const aiTools = [
"Remove Background",
"Image Upscaling",
"Face / PII Blur",
"Object Eraser",
"OCR / Text Extraction",
"PDF OCR",
"AI Colorization",
"Face Enhancement",
"Noise Removal",
"Smart Crop",
"Red Eye Removal",
"Photo Restoration",
"Passport Photo",
"PNG Transparency Fixer",
"AI Canvas Expand",
"Transcribe Audio",
"Auto Subtitles",
"Background Replace",
"Blur Background",
];
const aiPillColors = [
"border-purple-200 bg-purple-50 text-purple-700",
"border-blue-200 bg-blue-50 text-blue-700",
"border-emerald-200 bg-emerald-50 text-emerald-700",
"border-amber-200 bg-amber-50 text-amber-700",
"border-rose-200 bg-rose-50 text-rose-700",
"border-slate-200 bg-slate-50 text-slate-700",
];
---
<section class="px-6 py-20 md:py-28">
<SectionHeading
title="Built for regulated environments."
subtitle="Your files stay on your infrastructure. No third-party processing, no data transfer risk."
/>
<div class="reveal mx-auto mb-16 max-w-3xl text-center md:mb-20">
<span class="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.2em] text-primary-dark">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Data sovereignty
</span>
<h2 class="mt-6 font-display text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
Built for regulated environments.
</h2>
<p class="mt-4 text-lg leading-relaxed text-muted md:text-xl">
Your files stay on your infrastructure. No third-party processing, no data transfer risk.
</p>
</div>
<div class="mx-auto max-w-6xl space-y-20 md:space-y-28">
<!-- Feature 1: Data Sovereignty — 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">
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">01 / Architecture</span>
<h3 class="mt-3 font-display text-2xl font-bold tracking-tight sm:text-3xl">
Compliant by architecture
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
@@ -69,7 +107,8 @@ import SectionHeading from "./SectionHeading.astro";
<!-- Feature 2: REST API — 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">
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">02 / Automation</span>
<h3 class="mt-3 font-display text-2xl font-bold tracking-tight sm:text-3xl">
Integrate with your existing workflows
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
@@ -95,49 +134,23 @@ import SectionHeading from "./SectionHeading.astro";
<!-- Feature 3: AI Models — 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">
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">03 / Local AI</span>
<h3 class="mt-3 font-display text-2xl font-bold tracking-tight sm:text-3xl">
AI processing without external APIs
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
15 local AI models for OCR, transcription, background removal, upscaling, and more.
19 local AI tools for OCR, transcription, background removal, upscaling, and more.
No external API keys, no per-request charges, no data sent to third-party AI providers.
Runs on your GPU or CPU within your own infrastructure.
</p>
</div>
<div class="md:order-2">
<div class="flex flex-wrap gap-2.5">
<span class="inline-flex items-center gap-1.5 rounded-full border border-purple-200 bg-purple-50 px-3.5 py-1.5 text-sm font-medium text-purple-700">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5z"/><path d="M9 3v18M3 15h18"/></svg>
Background Removal
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-3.5 py-1.5 text-sm font-medium text-blue-700">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
OCR
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3.5 py-1.5 text-sm font-medium text-emerald-700">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
Upscaling 4x
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-3.5 py-1.5 text-sm font-medium text-amber-700">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
Transcription
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-3.5 py-1.5 text-sm font-medium text-rose-700">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></svg>
Face Detection
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-orange-200 bg-orange-50 px-3.5 py-1.5 text-sm font-medium text-orange-700">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Photo Restoration
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-orange-200 bg-orange-50 px-3.5 py-1.5 text-sm font-medium text-orange-700">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="13.5" cy="6.5" r="2.5"/><circle cx="17" cy="15.5" r="2.5"/><circle cx="8.5" cy="15.5" r="2.5"/></svg>
Colorization
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3.5 py-1.5 text-sm font-medium text-slate-700">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H2v15h7c1.7 0 3 1.3 3 3V7c0-2.2-1.8-4-4-4z"/><path d="M16 3h6v15h-7c-1.7 0-3 1.3-3 3V7c0-2.2 1.8-4 4-4z"/></svg>
Noise Removal
</span>
{aiTools.map((tool, i) => (
<span class:list={["inline-flex items-center rounded-full border px-3.5 py-1.5 text-sm font-medium", aiPillColors[i % aiPillColors.length]]}>
{tool}
</span>
))}
</div>
</div>
</div>
@@ -145,12 +158,13 @@ import SectionHeading from "./SectionHeading.astro";
<!-- Feature 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">
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">04 / Deployment</span>
<h3 class="mt-3 font-display text-2xl font-bold tracking-tight sm:text-3xl">
Fits your infrastructure
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
Single Docker container with everything bundled. Deploy on Kubernetes, bare metal,
or cloud VMs. ARM and x86 architectures. Works in air-gapped and classified
One Docker Compose stack with Postgres and Redis bundled in. Deploy on Kubernetes,
bare metal, or cloud VMs. ARM and x86 architectures. Works in air-gapped and classified
environments with zero internet dependency.
</p>
</div>
+71 -84
View File
@@ -1,39 +1,20 @@
---
import { TOOLS } from "@snapotter/shared";
import TerminalBlock from "./TerminalBlock.astro";
import CategoryCards from "./CategoryCards.astro";
import HeroSearch from "./HeroSearch.astro";
import TrustSignals from "./TrustSignals.astro";
const toolCount = TOOLS.length;
const modalities = [
{
label: "Images",
href: "/tools",
icon: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
},
{
label: "Video",
href: "/tools",
icon: '<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>',
},
{
label: "Audio",
href: "/tools",
icon: '<path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>',
},
{
label: "PDF & Docs",
href: "/tools",
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"/>',
},
{
label: "Data Files",
href: "/tools",
icon: '<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"/>',
},
const trustBadges = [
"On-premise",
"Privacy-first",
"GDPR & HIPAA ready",
"Air-gap ready",
"SAML SSO",
"Audit logging",
"Open source",
];
---
<section class="relative overflow-hidden px-6 pt-32 pb-16 md:pt-44 md:pb-24">
<section class="relative overflow-hidden px-6 pt-28 pb-12 md:pt-32 md:pb-16">
<!-- Gradient mesh background -->
<div class="hero-mesh" aria-hidden="true"></div>
<div class="hero-mesh-extra" aria-hidden="true"></div>
@@ -41,62 +22,68 @@ const modalities = [
<div class="relative mx-auto max-w-4xl text-center">
<!-- Enterprise trust badges -->
<div class="animate-fade-up flex flex-wrap items-center justify-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary-dark">
<svg class="h-3 w-3" 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>
On-premise
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary-dark">
<svg class="h-3 w-3" 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>
GDPR &amp; HIPAA ready
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary-dark">
<svg class="h-3 w-3" 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>
Open source
</span>
<span class="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary-dark">
<svg class="h-3 w-3" 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>
SAML SSO
</span>
</div>
<!-- Headline: enterprise-first -->
<h1 class="animate-fade-up mt-8 font-display text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl" style="animation-delay: 0.1s;">
Every file tool your team needs.<br class="hidden sm:block" />
<span class="bg-gradient-to-r from-primary to-primary-light bg-clip-text text-transparent">Nothing leaves your network.</span>
</h1>
<!-- Subtitle: business outcome focused -->
<p class="animate-fade-up mx-auto mt-6 max-w-2xl text-lg text-muted md:text-xl" style="animation-delay: 0.15s;">
{toolCount} tools for images, video, audio, and documents. Deploy on your infrastructure with
SAML SSO, audit logging, and per-tool permissions. One platform, zero third-party risk.
</p>
<!-- Category pills -->
<div class="animate-fade-up mt-8 flex flex-wrap items-center justify-center gap-2" style="animation-delay: 0.2s;">
{modalities.map((mod) => (
<a
href={mod.href}
class="inline-flex items-center gap-2 rounded-full border border-border bg-surface px-4 py-2 text-sm font-medium transition-all hover:border-primary hover:bg-primary-subtle hover:shadow-sm"
>
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={mod.icon} />
{mod.label}
</a>
{trustBadges.map((badge) => (
<span class="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary-dark">
<svg class="h-3 w-3" 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>
{badge}
</span>
))}
</div>
<!-- CTAs: enterprise-first -->
<div class="animate-fade-up mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row" style="animation-delay: 0.25s;">
<a href="/contact" class="btn-primary rounded-xl px-8 py-3.5 text-base font-semibold">
Book a Demo
</a>
<a href="https://docs.snapotter.com/guide/getting-started" target="_blank" rel="noopener noreferrer" class="btn-outline rounded-xl px-8 py-3.5 text-base font-semibold">
Deploy Free
</a>
</div>
<!-- Headline: one sentence, rotating modality + fixed privacy promise -->
<h1 class="animate-fade-up mt-8 font-display text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl" style="animation-delay: 0.1s;">
Every <span class="word-rotator" aria-hidden="true"><span class="word-rotator-track"><span>file</span><span>image</span><span>video</span><span>audio</span><span>PDF</span><span>data</span><span>file</span></span></span><span class="sr-only">file</span> tool you need.
<span class="mt-3 block bg-gradient-to-r from-primary to-primary-light bg-clip-text text-2xl font-bold text-transparent sm:text-3xl md:text-4xl">Nothing leaves your network.</span>
</h1>
<!-- Terminal -->
<div class="animate-fade-up mx-auto mt-12 max-w-lg" style="animation-delay: 0.35s;">
<TerminalBlock command="docker run -d -p 1349:1349 snapotter/snapotter" />
</div>
<!-- Subtitle: one calm supporting line -->
<p class="animate-fade-up mx-auto mt-6 max-w-2xl text-lg text-muted md:text-xl" style="animation-delay: 0.15s;">
The file-processing suite for teams that keep sensitive data in-house.
</p>
<!-- Hero tool search -->
<HeroSearch />
<!-- Category cards -->
<CategoryCards />
<!-- Stats strip -->
<TrustSignals />
</div>
</section>
<style>
/* Pure-CSS rotating word (no client JS). Cycles the file type in the headline. */
.word-rotator {
display: inline-block;
height: 1em;
overflow: hidden;
vertical-align: top;
color: var(--color-primary);
}
.word-rotator-track {
display: flex;
flex-direction: column;
animation: word-rotate 13.2s infinite;
}
.word-rotator-track > span {
display: block;
height: 1em;
line-height: 1;
white-space: nowrap;
}
@keyframes word-rotate {
0%, 12% { transform: translateY(0); }
16.66%, 28.66% { transform: translateY(-1em); }
33.33%, 45.33% { transform: translateY(-2em); }
50%, 62% { transform: translateY(-3em); }
66.66%, 78.66% { transform: translateY(-4em); }
83.33%, 95.33% { transform: translateY(-5em); }
100% { transform: translateY(-6em); }
}
@media (prefers-reduced-motion: reduce) {
.word-rotator-track {
animation: none;
}
}
</style>
@@ -0,0 +1,159 @@
---
import { TOOLS } from "@snapotter/shared";
import * as lucideIcons from "lucide";
const MODALITY_META: Record<string, { label: string; color: string }> = {
image: { label: "Image", color: "#E07832" },
video: { label: "Video", color: "#BE4A3C" },
audio: { label: "Audio", color: "#2C7A75" },
document: { label: "PDF", color: "#44568C" },
file: { label: "Data", color: "#5C8642" },
};
function renderIcon(iconName: string): string {
const iconData = (lucideIcons as Record<string, unknown>)[iconName];
if (!iconData || !Array.isArray(iconData)) return "";
return iconData
.map(([tag, attrs]: [string, Record<string, string>]) => {
const attrStr = Object.entries(attrs)
.map(([k, v]) => `${k}="${v}"`)
.join(" ");
return `<${tag} ${attrStr}/>`;
})
.join("");
}
---
<div class="hero-search animate-fade-up relative z-30 mx-auto mt-8 max-w-xl" style="animation-delay: 0.18s;">
<div class="relative">
<svg class="pointer-events-none absolute top-1/2 left-5 h-5 w-5 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
type="text"
id="hero-tool-search"
autocomplete="off"
placeholder="Search tools"
class="w-full rounded-2xl border border-border bg-surface py-4 pr-4 pl-12 text-base shadow-sm outline-none transition-all placeholder:text-muted focus:border-primary focus:shadow-md"
aria-label="Search tools"
aria-expanded="false"
aria-controls="hero-search-results"
/>
</div>
<!-- Live results dropdown -->
<div
id="hero-search-results"
class="absolute z-20 mt-2 hidden w-full overflow-hidden rounded-2xl border border-border bg-surface text-start shadow-xl"
>
<ul id="hero-search-list" class="max-h-80 overflow-y-auto py-1.5">
{TOOLS.map((tool) => {
const mod = MODALITY_META[tool.modality];
return (
<li
class="hero-result"
data-name={tool.name.toLowerCase()}
data-desc={tool.description.toLowerCase()}
data-mod={(mod?.label || "").toLowerCase()}
hidden
>
<a
href={`/tools/${tool.id}`}
class="flex items-center gap-3 px-4 py-2.5 no-underline transition-colors hover:bg-primary-subtle"
>
<svg
class="h-5 w-5 shrink-0"
style={{ color: mod?.color }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
set:html={renderIcon(tool.icon)}
/>
<span class="flex-1 text-sm font-medium text-foreground">{tool.name}</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={`background-color: ${mod?.color}1a; color: ${mod?.color}`}
>
{mod?.label}
</span>
</a>
</li>
);
})}
</ul>
<p id="hero-search-empty" class="hidden px-4 py-3 text-sm text-muted">
No tools match. Try "resize", "convert", or "compress".
</p>
</div>
</div>
<script is:inline>
(function () {
var input = document.getElementById("hero-tool-search");
var box = document.getElementById("hero-search-results");
var empty = document.getElementById("hero-search-empty");
var items = Array.prototype.slice.call(
document.querySelectorAll("#hero-search-list .hero-result"),
);
var MAX = 7;
var firstHref = null;
function close() {
box.classList.add("hidden");
input.setAttribute("aria-expanded", "false");
}
function open() {
box.classList.remove("hidden");
input.setAttribute("aria-expanded", "true");
}
function run() {
var q = input.value.toLowerCase().trim();
if (!q) {
close();
return;
}
var tokens = q.split(/\s+/);
var shown = 0;
firstHref = null;
items.forEach(function (li) {
var hay =
(li.getAttribute("data-name") || "") +
" " +
(li.getAttribute("data-desc") || "") +
" " +
(li.getAttribute("data-mod") || "");
var hit = tokens.every(function (t) {
return hay.indexOf(t) !== -1;
});
if (hit && shown < MAX) {
li.hidden = false;
shown++;
if (!firstHref) firstHref = li.querySelector("a").getAttribute("href");
} else {
li.hidden = true;
}
});
empty.classList.toggle("hidden", shown !== 0);
open();
}
input.addEventListener("input", run);
input.addEventListener("focus", function () {
if (input.value.trim()) run();
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter" && firstHref) {
window.location.href = firstHref;
} else if (e.key === "Escape") {
close();
input.blur();
}
});
document.addEventListener("click", function (e) {
if (!box.contains(e.target) && e.target !== input) close();
});
})();
</script>
+37 -1
View File
@@ -56,7 +56,7 @@ const links = [
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<svg class="h-3.5 w-3.5 text-primary" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
<span class="text-xs">{starCount || "Star"}</span>
<span id="gh-stars" class="text-xs">{starCount || "1.6k"}</span>
</a>
<a
href="https://demo.snapotter.com"
@@ -130,3 +130,39 @@ const links = [
display: block;
}
</style>
<script is:inline>
// Live GitHub star count, cached in localStorage for 1 hour so a visitor browsing
// many pages only hits the API once. Falls back to the build-time value on failure.
(function () {
var el = document.getElementById("gh-stars");
if (!el) return;
var KEY = "snapotter:gh-stars";
var TTL = 3600000; // 1 hour
function fmt(n) {
return n >= 1000
? ((n / 1000) % 1 === 0 ? (n / 1000).toFixed(0) : (n / 1000).toFixed(1)) + "k"
: String(n);
}
try {
var cached = JSON.parse(localStorage.getItem(KEY) || "null");
if (cached && typeof cached.n === "number" && Date.now() - cached.t < TTL) {
el.textContent = fmt(cached.n);
return;
}
} catch (e) {}
fetch("https://api.github.com/repos/snapotter-hq/snapotter")
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (d) {
if (d && typeof d.stargazers_count === "number") {
el.textContent = fmt(d.stargazers_count);
try {
localStorage.setItem(KEY, JSON.stringify({ n: d.stargazers_count, t: Date.now() }));
} catch (e) {}
}
})
.catch(function () {});
})();
</script>
+5 -25
View File
@@ -34,26 +34,13 @@ function renderIcon(iconName: string): string {
<section class="bg-background-alt px-6 py-20 md:py-28" id="features">
<SectionHeading
title={`${toolCount} tools. One platform.`}
subtitle="Replace dozens of cloud subscriptions with a single deployment your team controls."
title="One platform. Every file tool."
subtitle="Browse 150+ self-hosted tools across image, video, audio, documents, and data."
/>
<div class="mx-auto max-w-6xl">
<!-- Search bar -->
<div class="mx-auto max-w-md">
<div class="relative">
<svg class="absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
type="text"
id="tool-search"
placeholder="Search tools..."
class="w-full rounded-xl border border-border bg-surface py-3 pr-4 pl-11 text-sm outline-none transition-colors placeholder:text-muted focus:border-primary"
/>
</div>
</div>
<!-- Modality pills -->
<div class="mt-6 flex flex-wrap items-center justify-center gap-2" id="modality-pills">
<div class="flex flex-wrap items-center justify-center gap-2" id="modality-pills">
{modalities.map((m) => (
<button
type="button"
@@ -125,7 +112,7 @@ function renderIcon(iconName: string): string {
<!-- Empty state -->
<p class="mt-8 hidden text-center text-muted" id="tool-empty">
No tools found. Try a different search.
No tools match these filters.
</p>
<!-- Browse all link -->
@@ -140,7 +127,6 @@ function renderIcon(iconName: string): string {
<script is:inline>
(function () {
var search = document.getElementById("tool-search");
var grid = document.getElementById("tool-grid");
var countEl = document.getElementById("tool-count");
var emptyEl = document.getElementById("tool-empty");
@@ -169,17 +155,13 @@ function renderIcon(iconName: string): string {
}
function filter() {
var q = search.value.toLowerCase().trim();
var visible = 0;
cards.forEach(function (card) {
var name = card.getAttribute("data-name") || "";
var desc = card.getAttribute("data-desc") || "";
var mod = card.getAttribute("data-modality") || "";
var cat = card.getAttribute("data-category") || "";
var matchSearch = !q || name.indexOf(q) !== -1 || desc.indexOf(q) !== -1;
var matchMod = activeModality === "all" || activeModality.split(",").indexOf(mod) !== -1;
var matchCat = !activeCategory || cat === activeCategory;
if (matchSearch && matchMod && matchCat) {
if (matchMod && matchCat) {
card.style.display = "";
visible++;
} else {
@@ -191,8 +173,6 @@ function renderIcon(iconName: string): string {
grid.style.display = visible === 0 ? "none" : "";
}
search.addEventListener("input", filter);
document.querySelectorAll("#modality-pills .tool-pill").forEach(function (btn) {
btn.addEventListener("click", function () {
activeModality = btn.getAttribute("data-modality");
+23 -54
View File
@@ -1,8 +1,4 @@
---
import { TOOLS } from "@snapotter/shared";
const toolCount = TOOLS.length;
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
@@ -22,54 +18,52 @@ try {
starCount = 0;
}
let dockerPulls = 0;
// Docker Hub pull count (public API, refreshed at build time)
let dockerHubPulls = 0;
try {
const res = await fetch("https://hub.docker.com/v2/repositories/snapotter/snapotter/");
if (res.ok) {
const data = await res.json();
dockerPulls = data.pull_count ?? 0;
dockerHubPulls = data.pull_count ?? 0;
}
} catch {
dockerPulls = 0;
dockerHubPulls = 0;
}
// GHCR (ghcr.io) has no public pull-count API, so this is a manual figure (update as it grows).
const GHCR_PULLS = 28_800;
const dockerForTotal = dockerHubPulls > 0 ? dockerHubPulls : 89_100;
const totalPulls = dockerForTotal + GHCR_PULLS;
const pullsDisplay =
totalPulls >= 1_000_000
? `${Math.floor(totalPulls / 100_000) / 10}M+`
: `${Math.floor(totalPulls / 100_000) * 100}K+`;
const stats = [
{
value: toolCount.toString(),
value: "150+",
label: "Processing Tools",
sublabel: "Across 5 file modalities",
},
{
value: starCount > 0 ? formatNumber(starCount) : "--",
value: starCount > 0 ? formatNumber(starCount) : "1.6k",
label: "GitHub Stars",
sublabel: "Open-source & growing",
href: "https://github.com/snapotter-hq/snapotter",
},
{
value: dockerPulls > 0 ? formatNumber(dockerPulls) : "--",
label: "Docker Pulls",
sublabel: "Deployed by organizations worldwide",
href: "https://hub.docker.com/r/snapotter/snapotter",
value: pullsDisplay,
label: "Image Pulls",
sublabel: "Deployed worldwide",
},
{
value: "21",
value: "20+",
label: "Languages",
sublabel: "Fully localized UI",
sublabel: "Speaks your language",
},
];
const badges = [
"GDPR Compliant",
"Air-Gapped Ready",
"SAML SSO",
"Audit Logging",
"Docker & K8s",
"ARM + x86",
];
---
<section class="border-y border-border bg-surface px-6 py-16 md:py-20">
<div class="mx-auto max-w-5xl">
<div class="relative mx-auto mt-10 max-w-5xl border-t border-border/70 pt-10">
<!-- Stats grid -->
<div class="reveal grid grid-cols-2 gap-10 sm:gap-12 lg:grid-cols-4">
@@ -84,33 +78,8 @@ const badges = [
</div>
);
return stat.href ? (
<a
href={stat.href}
target="_blank"
rel="noopener noreferrer"
class="group rounded-xl p-3 transition-colors hover:bg-background-alt"
>
{inner}
</a>
) : (
<div class="p-3">{inner}</div>
);
return <div class="p-3">{inner}</div>;
})}
</div>
<!-- Divider -->
<div class="reveal mx-auto my-10 h-px w-24 bg-border md:my-12" />
<!-- Enterprise compliance badges -->
<div class="reveal flex flex-wrap items-center justify-center gap-2.5">
{badges.map((badge) => (
<span class="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-3.5 py-1.5 text-xs font-medium text-muted">
<svg class="h-3.5 w-3.5 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>
{badge}
</span>
))}
</div>
</div>
</section>
</div>
+3 -3
View File
@@ -12,8 +12,8 @@ interface Props {
}
const toolCount = TOOLS.length;
const defaultTitle = `SnapOtter | Self-Hosted File Processing Suite`;
const defaultDescription = `${toolCount} file processing tools across 5 modalities with local AI. Runs 100% offline. Your files never leave your network. Open source and free forever.`;
const defaultTitle = `SnapOtter | Self-Hosted File Processing for Enterprise`;
const defaultDescription = `The self-hosted file-processing suite for teams that keep sensitive data in-house. ${toolCount} tools with local AI, SSO, audit logging, and full data control.`;
const {
title = defaultTitle,
@@ -56,7 +56,7 @@ const {
<meta property="og:image" content={`https://snapotter.com${ogImage}`} />
<meta property="og:image:width" content="1280" />
<meta property="og:image:height" content="640" />
<meta property="og:image:alt" content="SnapOtter - Self-Hosted File Processing Suite" />
<meta property="og:image:alt" content="SnapOtter - Self-Hosted File Processing for Enterprise" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
+1 -5
View File
@@ -4,13 +4,11 @@ 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;
@@ -109,11 +107,9 @@ const navSchema = {
<main id="main">
<Hero />
<TrustSignals />
<ToolGrid />
<FeatureHighlights />
<EnterpriseSection />
<HowItWorks />
<FeatureHighlights />
<Pricing />
<OpenSource />
</main>
+19 -6
View File
@@ -1,5 +1,6 @@
---
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, 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";
@@ -20,6 +21,20 @@ const catColor = category?.color ?? "#737373";
const isAiTool = (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(tool.id);
const toolCount = TOOLS.length;
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 title = seo ? `${seo.searchTitle} | SnapOtter` : `${tool.name} | SnapOtter`;
const description = seo?.longDescription ?? tool.description;
@@ -169,9 +184,8 @@ const allJsonLd = [
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="5" y="5" rx="2" />
</svg>
set:html={renderIcon(tool.icon)}
/>
</div>
<span
class="rounded-full px-3 py-1 text-xs font-semibold"
@@ -348,9 +362,8 @@ const allJsonLd = [
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="5" y="5" rx="2" />
</svg>
set:html={renderIcon(t.icon)}
/>
<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">
+114 -2
View File
@@ -27,6 +27,19 @@ const groups = CATEGORIES.map((cat) => ({
const toolCount = TOOLS.length;
const modalityFilters = [
{ id: "all", label: "All", count: TOOLS.length },
{ id: "image", label: "Image", count: TOOLS.filter((t) => t.modality === "image").length },
{ id: "video", label: "Video", count: TOOLS.filter((t) => t.modality === "video").length },
{ id: "audio", label: "Audio", count: TOOLS.filter((t) => t.modality === "audio").length },
{
id: "document",
label: "PDF & Docs",
count: TOOLS.filter((t) => t.modality === "document").length,
},
{ id: "file", label: "Data", count: TOOLS.filter((t) => t.modality === "file").length },
];
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
@@ -97,9 +110,40 @@ const collectionJsonLd = {
</div>
</section>
<!-- Filter bar -->
<section class="sticky top-16 z-30 border-y border-border bg-background/95 backdrop-blur">
<div class="mx-auto max-w-5xl px-6 py-4">
<div class="relative">
<svg class="pointer-events-none absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
id="tools-search"
type="text"
autocomplete="off"
placeholder={`Search ${toolCount} tools by name...`}
class="w-full rounded-xl border border-border bg-surface py-2.5 pr-4 pl-11 text-sm outline-none transition-colors placeholder:text-muted focus:border-primary"
/>
</div>
<div class="mt-3 flex flex-wrap gap-2">
{modalityFilters.map((m) => (
<button
type="button"
data-modality={m.id}
class:list={[
"tools-mod-btn shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
m.id === "all" ? "bg-primary text-white" : "border border-border bg-surface hover:bg-background-alt",
]}
>
{m.label} ({m.count})
</button>
))}
</div>
<p id="tools-count" class="mt-3 text-sm text-muted">Showing all {toolCount} tools</p>
</div>
</section>
<!-- Tool categories -->
{groups.map((group) => (
<section class="border-t border-border py-12 even:bg-background-alt">
<section class="border-t border-border py-12 even:bg-background-alt" data-cat-section>
<div class="mx-auto max-w-5xl px-6">
<div class="reveal">
<div class="mb-8 flex items-center gap-3">
@@ -142,7 +186,10 @@ const collectionJsonLd = {
{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"
class="tool-item group flex items-start gap-4 rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
data-name={tool.name.toLowerCase()}
data-desc={tool.description.toLowerCase()}
data-modality={tool.modality}
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
@@ -179,6 +226,11 @@ const collectionJsonLd = {
</section>
))}
<!-- Empty state -->
<p id="tools-empty" class="hidden border-t border-border py-20 text-center text-muted">
No tools match your search. Try a different term or modality.
</p>
<!-- Bottom CTA -->
<section class="border-t border-border py-20">
<div class="mx-auto max-w-5xl px-6 text-center">
@@ -213,5 +265,65 @@ const collectionJsonLd = {
</section>
</main>
<script is:inline>
(function () {
var search = document.getElementById("tools-search");
var countEl = document.getElementById("tools-count");
var emptyEl = document.getElementById("tools-empty");
var items = Array.prototype.slice.call(document.querySelectorAll(".tool-item"));
var sections = Array.prototype.slice.call(document.querySelectorAll("[data-cat-section]"));
var modBtns = Array.prototype.slice.call(document.querySelectorAll(".tools-mod-btn"));
var total = items.length;
var activeMod = "all";
function setBtns() {
modBtns.forEach(function (b) {
var on = b.getAttribute("data-modality") === activeMod;
b.className =
"tools-mod-btn shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors " +
(on ? "bg-primary text-white" : "border border-border bg-surface hover:bg-background-alt");
});
}
function filter() {
var q = search.value.toLowerCase().trim();
var visible = 0;
items.forEach(function (el) {
var matchMod = activeMod === "all" || el.getAttribute("data-modality") === activeMod;
var matchQ =
!q ||
el.getAttribute("data-name").indexOf(q) !== -1 ||
el.getAttribute("data-desc").indexOf(q) !== -1;
var show = matchMod && matchQ;
el.style.display = show ? "" : "none";
if (show) visible++;
});
sections.forEach(function (sec) {
var anyVisible = Array.prototype.some.call(
sec.querySelectorAll(".tool-item"),
function (el) {
return el.style.display !== "none";
},
);
sec.style.display = anyVisible ? "" : "none";
});
emptyEl.style.display = visible === 0 ? "block" : "none";
countEl.textContent =
q || activeMod !== "all"
? "Showing " + visible + " of " + total + " tools"
: "Showing all " + total + " tools";
}
search.addEventListener("input", filter);
modBtns.forEach(function (b) {
b.addEventListener("click", function () {
activeMod = b.getAttribute("data-modality");
setBtns();
filter();
});
});
})();
</script>
<Footer />
</Base>