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 = [
|
const features = [
|
||||||
{
|
{
|
||||||
|
tag: "Identity",
|
||||||
title: "SAML SSO",
|
title: "SAML SSO",
|
||||||
description: "Okta, Azure AD, Google Workspace, or any SAML 2.0 / OIDC identity provider.",
|
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"/>',
|
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",
|
title: "SCIM Provisioning",
|
||||||
description:
|
description:
|
||||||
"Automated user provisioning and deprovisioning synced from your identity provider.",
|
"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"/>',
|
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",
|
title: "Multi-Factor Auth",
|
||||||
description: "Enforce TOTP-based MFA across your organization. Protect every account.",
|
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"/>',
|
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Multi-Tenancy",
|
tag: "Access",
|
||||||
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"/>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Per-Tool Permissions",
|
title: "Per-Tool Permissions",
|
||||||
description: "Granular role-based access control. Admin, editor, user, and custom roles.",
|
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"/>',
|
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",
|
title: "Audit Export",
|
||||||
description:
|
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"/>',
|
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",
|
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"/>',
|
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",
|
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"/>',
|
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">
|
<section class="section-dark bg-noise relative overflow-hidden px-6 py-24 md:py-32" id="enterprise">
|
||||||
<div class="reveal mx-auto max-w-3xl text-center">
|
<!-- Atmosphere: warm security glow -->
|
||||||
<h2 class="font-display text-3xl font-bold tracking-tight text-dark-fg sm:text-4xl md:text-5xl">
|
<div
|
||||||
Built for enterprise deployment.
|
class="pointer-events-none absolute inset-x-0 top-0 mx-auto h-96 max-w-3xl rounded-full bg-primary/10 blur-[120px]"
|
||||||
</h2>
|
aria-hidden="true"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto mt-14 grid max-w-5xl gap-4 sm:grid-cols-2">
|
<div class="relative mx-auto max-w-6xl">
|
||||||
{features.map((feature, i) => (
|
<!-- Header -->
|
||||||
<div class:list={["reveal card-dark border border-dark-border rounded-xl p-6", `reveal-delay-${Math.min(i + 1, 4)}`]}>
|
<div class="reveal mx-auto max-w-3xl text-center">
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
|
<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-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} />
|
<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>
|
</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">
|
<!-- Compliance frameworks -->
|
||||||
<a
|
<div class="reveal mt-12 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||||||
href="/contact"
|
<span class="text-xs font-medium uppercase tracking-[0.15em] text-dark-muted">Audit-ready for</span>
|
||||||
class="btn-primary inline-block rounded-xl px-8 py-3 text-base font-semibold"
|
<div class="flex flex-wrap items-center justify-center gap-2.5">
|
||||||
>
|
{frameworks.map((fw) => (
|
||||||
Book a Demo
|
<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">
|
||||||
</a>
|
<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>
|
||||||
<p class="mt-4 text-sm text-dark-muted">Talk to our team about enterprise licensing and deployment.</p>
|
{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>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="px-6 py-20 md:py-28">
|
||||||
<SectionHeading
|
<div class="reveal mx-auto mb-16 max-w-3xl text-center md:mb-20">
|
||||||
title="Built for regulated environments."
|
<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">
|
||||||
subtitle="Your files stay on your infrastructure. No third-party processing, no data transfer risk."
|
<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">
|
<div class="mx-auto max-w-6xl space-y-20 md:space-y-28">
|
||||||
|
|
||||||
<!-- Feature 1: Data Sovereignty — text left, visual right -->
|
<!-- 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="reveal grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
|
||||||
<div class="md:order-1">
|
<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
|
Compliant by architecture
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-4 text-base leading-relaxed text-muted">
|
<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 -->
|
<!-- 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="reveal grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
|
||||||
<div class="md:order-2">
|
<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
|
Integrate with your existing workflows
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-4 text-base leading-relaxed text-muted">
|
<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 -->
|
<!-- 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="reveal grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
|
||||||
<div class="md:order-1">
|
<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
|
AI processing without external APIs
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-4 text-base leading-relaxed text-muted">
|
<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.
|
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.
|
Runs on your GPU or CPU within your own infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:order-2">
|
<div class="md:order-2">
|
||||||
<div class="flex flex-wrap gap-2.5">
|
<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">
|
{aiTools.map((tool, i) => (
|
||||||
<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>
|
<span class:list={["inline-flex items-center rounded-full border px-3.5 py-1.5 text-sm font-medium", aiPillColors[i % aiPillColors.length]]}>
|
||||||
Background Removal
|
{tool}
|
||||||
</span>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,12 +158,13 @@ import SectionHeading from "./SectionHeading.astro";
|
|||||||
<!-- Feature 4: Deployment — visual left, text right -->
|
<!-- 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="reveal grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
|
||||||
<div class="md:order-2">
|
<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
|
Fits your infrastructure
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-4 text-base leading-relaxed text-muted">
|
<p class="mt-4 text-base leading-relaxed text-muted">
|
||||||
Single Docker container with everything bundled. Deploy on Kubernetes, bare metal,
|
One Docker Compose stack with Postgres and Redis bundled in. Deploy on Kubernetes,
|
||||||
or cloud VMs. ARM and x86 architectures. Works in air-gapped and classified
|
bare metal, or cloud VMs. ARM and x86 architectures. Works in air-gapped and classified
|
||||||
environments with zero internet dependency.
|
environments with zero internet dependency.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,20 @@
|
|||||||
---
|
---
|
||||||
import { TOOLS } from "@snapotter/shared";
|
import CategoryCards from "./CategoryCards.astro";
|
||||||
import TerminalBlock from "./TerminalBlock.astro";
|
import HeroSearch from "./HeroSearch.astro";
|
||||||
|
import TrustSignals from "./TrustSignals.astro";
|
||||||
|
|
||||||
const toolCount = TOOLS.length;
|
const trustBadges = [
|
||||||
|
"On-premise",
|
||||||
const modalities = [
|
"Privacy-first",
|
||||||
{
|
"GDPR & HIPAA ready",
|
||||||
label: "Images",
|
"Air-gap ready",
|
||||||
href: "/tools",
|
"SAML SSO",
|
||||||
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"/>',
|
"Audit logging",
|
||||||
},
|
"Open source",
|
||||||
{
|
|
||||||
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"/>',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<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 -->
|
<!-- Gradient mesh background -->
|
||||||
<div class="hero-mesh" aria-hidden="true"></div>
|
<div class="hero-mesh" aria-hidden="true"></div>
|
||||||
<div class="hero-mesh-extra" 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">
|
<div class="relative mx-auto max-w-4xl text-center">
|
||||||
<!-- Enterprise trust badges -->
|
<!-- Enterprise trust badges -->
|
||||||
<div class="animate-fade-up flex flex-wrap items-center justify-center gap-2">
|
<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">
|
{trustBadges.map((badge) => (
|
||||||
<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>
|
<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">
|
||||||
On-premise
|
<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>
|
||||||
</span>
|
{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">
|
</span>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CTAs: enterprise-first -->
|
<!-- Headline: one sentence, rotating modality + fixed privacy promise -->
|
||||||
<div class="animate-fade-up mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row" style="animation-delay: 0.25s;">
|
<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;">
|
||||||
<a href="/contact" class="btn-primary rounded-xl px-8 py-3.5 text-base font-semibold">
|
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.
|
||||||
Book a Demo
|
<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>
|
||||||
</a>
|
</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Terminal -->
|
<!-- Subtitle: one calm supporting line -->
|
||||||
<div class="animate-fade-up mx-auto mt-12 max-w-lg" style="animation-delay: 0.35s;">
|
<p class="animate-fade-up mx-auto mt-6 max-w-2xl text-lg text-muted md:text-xl" style="animation-delay: 0.15s;">
|
||||||
<TerminalBlock command="docker run -d -p 1349:1349 snapotter/snapotter" />
|
The file-processing suite for teams that keep sensitive data in-house.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
|
<!-- Hero tool search -->
|
||||||
|
<HeroSearch />
|
||||||
|
|
||||||
|
<!-- Category cards -->
|
||||||
|
<CategoryCards />
|
||||||
|
|
||||||
|
<!-- Stats strip -->
|
||||||
|
<TrustSignals />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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-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>
|
<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>
|
||||||
<a
|
<a
|
||||||
href="https://demo.snapotter.com"
|
href="https://demo.snapotter.com"
|
||||||
@@ -130,3 +130,39 @@ const links = [
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<section class="bg-background-alt px-6 py-20 md:py-28" id="features">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={`${toolCount} tools. One platform.`}
|
title="One platform. Every file tool."
|
||||||
subtitle="Replace dozens of cloud subscriptions with a single deployment your team controls."
|
subtitle="Browse 150+ self-hosted tools across image, video, audio, documents, and data."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<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 -->
|
<!-- 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) => (
|
{modalities.map((m) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -125,7 +112,7 @@ function renderIcon(iconName: string): string {
|
|||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<p class="mt-8 hidden text-center text-muted" id="tool-empty">
|
<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>
|
</p>
|
||||||
|
|
||||||
<!-- Browse all link -->
|
<!-- Browse all link -->
|
||||||
@@ -140,7 +127,6 @@ function renderIcon(iconName: string): string {
|
|||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function () {
|
(function () {
|
||||||
var search = document.getElementById("tool-search");
|
|
||||||
var grid = document.getElementById("tool-grid");
|
var grid = document.getElementById("tool-grid");
|
||||||
var countEl = document.getElementById("tool-count");
|
var countEl = document.getElementById("tool-count");
|
||||||
var emptyEl = document.getElementById("tool-empty");
|
var emptyEl = document.getElementById("tool-empty");
|
||||||
@@ -169,17 +155,13 @@ function renderIcon(iconName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filter() {
|
function filter() {
|
||||||
var q = search.value.toLowerCase().trim();
|
|
||||||
var visible = 0;
|
var visible = 0;
|
||||||
cards.forEach(function (card) {
|
cards.forEach(function (card) {
|
||||||
var name = card.getAttribute("data-name") || "";
|
|
||||||
var desc = card.getAttribute("data-desc") || "";
|
|
||||||
var mod = card.getAttribute("data-modality") || "";
|
var mod = card.getAttribute("data-modality") || "";
|
||||||
var cat = card.getAttribute("data-category") || "";
|
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 matchMod = activeModality === "all" || activeModality.split(",").indexOf(mod) !== -1;
|
||||||
var matchCat = !activeCategory || cat === activeCategory;
|
var matchCat = !activeCategory || cat === activeCategory;
|
||||||
if (matchSearch && matchMod && matchCat) {
|
if (matchMod && matchCat) {
|
||||||
card.style.display = "";
|
card.style.display = "";
|
||||||
visible++;
|
visible++;
|
||||||
} else {
|
} else {
|
||||||
@@ -191,8 +173,6 @@ function renderIcon(iconName: string): string {
|
|||||||
grid.style.display = visible === 0 ? "none" : "";
|
grid.style.display = visible === 0 ? "none" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
search.addEventListener("input", filter);
|
|
||||||
|
|
||||||
document.querySelectorAll("#modality-pills .tool-pill").forEach(function (btn) {
|
document.querySelectorAll("#modality-pills .tool-pill").forEach(function (btn) {
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
activeModality = btn.getAttribute("data-modality");
|
activeModality = btn.getAttribute("data-modality");
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
---
|
---
|
||||||
import { TOOLS } from "@snapotter/shared";
|
|
||||||
|
|
||||||
const toolCount = TOOLS.length;
|
|
||||||
|
|
||||||
function formatNumber(n: number): string {
|
function formatNumber(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
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`;
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||||
@@ -22,54 +18,52 @@ try {
|
|||||||
starCount = 0;
|
starCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dockerPulls = 0;
|
// Docker Hub pull count (public API, refreshed at build time)
|
||||||
|
let dockerHubPulls = 0;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://hub.docker.com/v2/repositories/snapotter/snapotter/");
|
const res = await fetch("https://hub.docker.com/v2/repositories/snapotter/snapotter/");
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
dockerPulls = data.pull_count ?? 0;
|
dockerHubPulls = data.pull_count ?? 0;
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 = [
|
const stats = [
|
||||||
{
|
{
|
||||||
value: toolCount.toString(),
|
value: "150+",
|
||||||
label: "Processing Tools",
|
label: "Processing Tools",
|
||||||
sublabel: "Across 5 file modalities",
|
sublabel: "Across 5 file modalities",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: starCount > 0 ? formatNumber(starCount) : "--",
|
value: starCount > 0 ? formatNumber(starCount) : "1.6k",
|
||||||
label: "GitHub Stars",
|
label: "GitHub Stars",
|
||||||
sublabel: "Open-source & growing",
|
sublabel: "Open-source & growing",
|
||||||
href: "https://github.com/snapotter-hq/snapotter",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: dockerPulls > 0 ? formatNumber(dockerPulls) : "--",
|
value: pullsDisplay,
|
||||||
label: "Docker Pulls",
|
label: "Image Pulls",
|
||||||
sublabel: "Deployed by organizations worldwide",
|
sublabel: "Deployed worldwide",
|
||||||
href: "https://hub.docker.com/r/snapotter/snapotter",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "21",
|
value: "20+",
|
||||||
label: "Languages",
|
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="relative mx-auto mt-10 max-w-5xl border-t border-border/70 pt-10">
|
||||||
<div class="mx-auto max-w-5xl">
|
|
||||||
|
|
||||||
<!-- Stats grid -->
|
<!-- Stats grid -->
|
||||||
<div class="reveal grid grid-cols-2 gap-10 sm:gap-12 lg:grid-cols-4">
|
<div class="reveal grid grid-cols-2 gap-10 sm:gap-12 lg:grid-cols-4">
|
||||||
@@ -84,33 +78,8 @@ const badges = [
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return stat.href ? (
|
return <div class="p-3">{inner}</div>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Divider -->
|
</div>
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolCount = TOOLS.length;
|
const toolCount = TOOLS.length;
|
||||||
const defaultTitle = `SnapOtter | Self-Hosted File Processing Suite`;
|
const defaultTitle = `SnapOtter | Self-Hosted File Processing for Enterprise`;
|
||||||
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 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 {
|
const {
|
||||||
title = defaultTitle,
|
title = defaultTitle,
|
||||||
@@ -56,7 +56,7 @@ const {
|
|||||||
<meta property="og:image" content={`https://snapotter.com${ogImage}`} />
|
<meta property="og:image" content={`https://snapotter.com${ogImage}`} />
|
||||||
<meta property="og:image:width" content="1280" />
|
<meta property="og:image:width" content="1280" />
|
||||||
<meta property="og:image:height" content="640" />
|
<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 -->
|
<!-- Twitter -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<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 FeatureHighlights from "@/components/FeatureHighlights.astro";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import Hero from "@/components/Hero.astro";
|
import Hero from "@/components/Hero.astro";
|
||||||
import HowItWorks from "@/components/HowItWorks.astro";
|
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
import Navbar from "@/components/Navbar.astro";
|
import Navbar from "@/components/Navbar.astro";
|
||||||
import OpenSource from "@/components/OpenSource.astro";
|
import OpenSource from "@/components/OpenSource.astro";
|
||||||
import Pricing from "@/components/Pricing.astro";
|
import Pricing from "@/components/Pricing.astro";
|
||||||
import ToolGrid from "@/components/ToolGrid.astro";
|
import ToolGrid from "@/components/ToolGrid.astro";
|
||||||
import TrustSignals from "@/components/TrustSignals.astro";
|
|
||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.astro";
|
||||||
|
|
||||||
const toolCount = TOOLS.length;
|
const toolCount = TOOLS.length;
|
||||||
@@ -109,11 +107,9 @@ const navSchema = {
|
|||||||
|
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<Hero />
|
<Hero />
|
||||||
<TrustSignals />
|
|
||||||
<ToolGrid />
|
<ToolGrid />
|
||||||
<FeatureHighlights />
|
|
||||||
<EnterpriseSection />
|
<EnterpriseSection />
|
||||||
<HowItWorks />
|
<FeatureHighlights />
|
||||||
<Pricing />
|
<Pricing />
|
||||||
<OpenSource />
|
<OpenSource />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
|
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
|
||||||
|
import * as lucideIcons from "lucide";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
import Navbar from "@/components/Navbar.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 isAiTool = (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(tool.id);
|
||||||
const toolCount = TOOLS.length;
|
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 title = seo ? `${seo.searchTitle} | SnapOtter` : `${tool.name} | SnapOtter`;
|
||||||
const description = seo?.longDescription ?? tool.description;
|
const description = seo?.longDescription ?? tool.description;
|
||||||
|
|
||||||
@@ -169,9 +184,8 @@ const allJsonLd = [
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
>
|
set:html={renderIcon(tool.icon)}
|
||||||
<rect width="14" height="14" x="5" y="5" rx="2" />
|
/>
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
@@ -348,9 +362,8 @@ const allJsonLd = [
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
>
|
set:html={renderIcon(t.icon)}
|
||||||
<rect width="14" height="14" x="5" y="5" rx="2" />
|
/>
|
||||||
</svg>
|
|
||||||
<span class="mt-3 text-sm font-bold">{t.name}</span>
|
<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>
|
<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">
|
<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 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 = {
|
const breadcrumbJsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "BreadcrumbList",
|
"@type": "BreadcrumbList",
|
||||||
@@ -97,9 +110,40 @@ const collectionJsonLd = {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Tool categories -->
|
||||||
{groups.map((group) => (
|
{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="mx-auto max-w-5xl px-6">
|
||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<div class="mb-8 flex items-center gap-3">
|
<div class="mb-8 flex items-center gap-3">
|
||||||
@@ -142,7 +186,10 @@ const collectionJsonLd = {
|
|||||||
{group.tools.map((tool) => (
|
{group.tools.map((tool) => (
|
||||||
<a
|
<a
|
||||||
href={`/tools/${tool.id}`}
|
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
|
<div
|
||||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||||
@@ -179,6 +226,11 @@ const collectionJsonLd = {
|
|||||||
</section>
|
</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 -->
|
<!-- Bottom CTA -->
|
||||||
<section class="border-t border-border py-20">
|
<section class="border-t border-border py-20">
|
||||||
<div class="mx-auto max-w-5xl px-6 text-center">
|
<div class="mx-auto max-w-5xl px-6 text-center">
|
||||||
@@ -213,5 +265,65 @@ const collectionJsonLd = {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</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 />
|
<Footer />
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
Reference in New Issue
Block a user