mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user