feat(i18n): 21-language pipeline, landing/docs/API wiring, landing+API translations

Shared Claude Code translation pipeline (scripts/i18n, no API key) plus Astro/VitePress/Scalar i18n wiring. Landing and API reference translated into all 20 languages; docs i18n wiring + English source anchors. The translated docs markdown (apps/docs/<locale>/**, 3,620 files) follows in a companion PR because it exceeds GitHub's per-PR CI file limit.
This commit is contained in:
SnapOtter
2026-07-11 13:01:55 +08:00
committed by GitHub
parent 2e91368816
commit 00b651c9f8
353 changed files with 564607 additions and 2594 deletions
+20 -13
View File
@@ -2,6 +2,13 @@
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import { t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
function renderIcon(iconName: string): string {
const iconData = (lucideIcons as Record<string, unknown>)[iconName];
@@ -20,47 +27,47 @@ const MODALITIES = [
{
id: "image",
section: "image",
label: "Image Tools",
blurb: "Edit, resize, and enhance",
label: t(locale, "home.categoryCards.image.label"),
blurb: t(locale, "home.categoryCards.image.blurb"),
icon: "Image",
color: "#E07832",
},
{
id: "video",
section: "video",
label: "Video Tools",
blurb: "Trim, convert, and caption",
label: t(locale, "home.categoryCards.video.label"),
blurb: t(locale, "home.categoryCards.video.blurb"),
icon: "Video",
color: "#BE4A3C",
},
{
id: "audio",
section: "audio",
label: "Audio Tools",
blurb: "Convert, trim, and transcribe",
label: t(locale, "home.categoryCards.audio.label"),
blurb: t(locale, "home.categoryCards.audio.blurb"),
icon: "Music",
color: "#2C7A75",
},
{
id: "document",
section: "pdf",
label: "PDF & Documents",
blurb: "Merge, split, and convert",
label: t(locale, "home.categoryCards.document.label"),
blurb: t(locale, "home.categoryCards.document.blurb"),
icon: "FileText",
color: "#44568C",
},
{
id: "file",
section: "files",
label: "File Tools",
blurb: "Convert and transform",
label: t(locale, "home.categoryCards.file.label"),
blurb: t(locale, "home.categoryCards.file.blurb"),
icon: "Database",
color: "#5C8642",
},
];
const cards = MODALITIES.map((m) => {
const tools = TOOLS.filter((t) => toolSection(t) === m.section);
const tools = TOOLS.filter((tool) => toolSection(tool) === m.section);
return { ...m, count: tools.length };
});
---
@@ -69,7 +76,7 @@ const cards = MODALITIES.map((m) => {
<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/${c.section}/`}
href={localizeHref(locale, `/tools/${c.section}/`)}
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}`}
>
@@ -77,7 +84,7 @@ const cards = MODALITIES.map((m) => {
<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>
<span class="rounded-full bg-white/20 px-2.5 py-0.5 text-xs font-semibold">{t(locale, "home.categoryCards.toolCount", { count: c.count })}</span>
</div>
<div class="mt-4 flex items-center gap-1.5">
@@ -1,55 +1,60 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
const features = [
{
tag: "Identity",
title: "SAML SSO",
description: "Okta, Azure AD, Google Workspace, or any SAML 2.0 / OIDC identity provider.",
tag: t(locale, "home.enterprise.tag.identity"),
title: t(locale, "home.enterprise.feature.sso.title"),
description: t(locale, "home.enterprise.feature.sso.description"),
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.",
tag: t(locale, "home.enterprise.tag.identity"),
title: t(locale, "home.enterprise.feature.scim.title"),
description: t(locale, "home.enterprise.feature.scim.description"),
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.",
tag: t(locale, "home.enterprise.tag.access"),
title: t(locale, "home.enterprise.feature.mfa.title"),
description: t(locale, "home.enterprise.feature.mfa.description"),
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
},
{
tag: "Access",
title: "Per-Tool Permissions",
description: "Granular role-based access control. Admin, editor, user, and custom roles.",
tag: t(locale, "home.enterprise.tag.access"),
title: t(locale, "home.enterprise.feature.permissions.title"),
description: t(locale, "home.enterprise.feature.permissions.description"),
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.",
tag: t(locale, "home.enterprise.tag.isolation"),
title: t(locale, "home.enterprise.feature.multiTenancy.title"),
description: t(locale, "home.enterprise.feature.multiTenancy.description"),
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 immutable audit trails for SOC 2, ISO 27001, and compliance reporting.",
tag: t(locale, "home.enterprise.tag.compliance"),
title: t(locale, "home.enterprise.feature.audit.title"),
description: t(locale, "home.enterprise.feature.audit.description"),
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, or Cloudflare R2 for persistent, scalable file storage.",
tag: t(locale, "home.enterprise.tag.infrastructure"),
title: t(locale, "home.enterprise.feature.s3.title"),
description: t(locale, "home.enterprise.feature.s3.description"),
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 internal workflow.",
tag: t(locale, "home.enterprise.tag.integration"),
title: t(locale, "home.enterprise.feature.webhooks.title"),
description: t(locale, "home.enterprise.feature.webhooks.description"),
icon: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
},
];
@@ -70,13 +75,13 @@ const frameworks = ["SOC 2", "ISO 27001", "HIPAA", "GDPR"];
<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
{t(locale, "home.enterprise.badge")}
</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.
{t(locale, "home.enterprise.title")}
</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.
{t(locale, "home.enterprise.subtitle")}
</p>
</div>
@@ -112,7 +117,7 @@ const frameworks = ["SOC 2", "ISO 27001", "HIPAA", "GDPR"];
<!-- 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>
<span class="text-xs font-medium uppercase tracking-[0.15em] text-dark-muted">{t(locale, "home.enterprise.auditReadyFor")}</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">
@@ -125,10 +130,10 @@ const frameworks = ["SOC 2", "ISO 27001", "HIPAA", "GDPR"];
<!-- 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 href={localizeHref(locale, "/contact")} class="btn-primary inline-block rounded-xl px-8 py-3 text-base font-semibold">
{t(locale, "home.enterprise.bookDemo")}
</a>
<p class="mt-4 text-sm text-dark-muted">Talk to our team about enterprise licensing and deployment.</p>
<p class="mt-4 text-sm text-dark-muted">{t(locale, "home.enterprise.ctaBody")}</p>
</div>
</div>
</section>
@@ -1,25 +1,32 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { t } from "@/i18n";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
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",
t(locale, "home.features.ai.removeBackground"),
t(locale, "home.features.ai.upscale"),
t(locale, "home.features.ai.faceBlur"),
t(locale, "home.features.ai.objectEraser"),
t(locale, "home.features.ai.ocr"),
t(locale, "home.features.ai.pdfOcr"),
t(locale, "home.features.ai.colorization"),
t(locale, "home.features.ai.faceEnhance"),
t(locale, "home.features.ai.noiseRemoval"),
t(locale, "home.features.ai.smartCrop"),
t(locale, "home.features.ai.redEye"),
t(locale, "home.features.ai.restoration"),
t(locale, "home.features.ai.passport"),
t(locale, "home.features.ai.pngTransparency"),
t(locale, "home.features.ai.canvasExpand"),
t(locale, "home.features.ai.transcribe"),
t(locale, "home.features.ai.subtitles"),
t(locale, "home.features.ai.bgReplace"),
t(locale, "home.features.ai.blurBackground"),
];
const aiPillColors = [
@@ -36,13 +43,13 @@ const aiPillColors = [
<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
{t(locale, "home.features.badge")}
</span>
<h2 class="mt-6 font-display text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
Built for regulated environments.
{t(locale, "home.features.title")}
</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.
{t(locale, "home.features.subtitle")}
</p>
</div>
@@ -51,21 +58,19 @@ const aiPillColors = [
<!-- 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">
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">01 / Architecture</span>
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">{t(locale, "home.features.f1.kicker")}</span>
<h3 class="mt-3 font-display text-2xl font-bold tracking-tight sm:text-3xl">
Compliant by architecture
{t(locale, "home.features.f1.title")}
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
No vendor DPAs. No data processing agreements. No third-party risk assessments.
Files are processed on your infrastructure and never transmitted to external services.
GDPR, HIPAA, and CCPA compliant from day one.
{t(locale, "home.features.f1.body")}
</p>
</div>
<div class="md:order-2">
<div class="rounded-xl border border-border bg-surface p-6 md:p-8">
<!-- Outer infrastructure box -->
<div class="rounded-lg border-2 border-dashed border-primary/30 p-5">
<span class="mb-4 block text-xs font-semibold uppercase tracking-wider text-primary">Your Infrastructure</span>
<span class="mb-4 block text-xs font-semibold uppercase tracking-wider text-primary">{t(locale, "home.features.f1.yourInfra")}</span>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col items-center gap-2 rounded-lg bg-primary-subtle p-4">
<svg class="h-8 w-8 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
@@ -73,14 +78,14 @@ const aiPillColors = [
<path d="M8 12h8M8 16h5" />
<circle cx="18" cy="7" r="1" fill="currentColor" />
</svg>
<span class="text-xs font-semibold text-primary">SnapOtter</span>
<span class="text-xs font-semibold text-primary">{t(locale, "home.features.f1.snapotter")}</span>
</div>
<div class="flex flex-col items-center gap-2 rounded-lg bg-primary-subtle p-4">
<svg class="h-8 w-8 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
<span class="text-xs font-semibold text-primary">Your Files</span>
<span class="text-xs font-semibold text-primary">{t(locale, "home.features.f1.yourFiles")}</span>
</div>
</div>
<!-- Internal arrows -->
@@ -92,14 +97,14 @@ const aiPillColors = [
<polygon points="48,12 40,8 48,4" fill="currentColor" stroke="none" />
</svg>
</div>
<p class="mt-3 text-center text-xs text-muted">Data never leaves this boundary</p>
<p class="mt-3 text-center text-xs text-muted">{t(locale, "home.features.f1.dataBoundary")}</p>
</div>
<!-- External cloud — blocked -->
<div class="mt-4 flex items-center justify-center gap-3 rounded-lg border border-danger/20 bg-danger/5 px-4 py-2.5">
<svg class="h-4 w-4 shrink-0 text-danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<span class="text-xs text-danger">No external cloud connections</span>
<span class="text-xs text-danger">{t(locale, "home.features.f1.noExternal")}</span>
</div>
</div>
</div>
@@ -108,23 +113,21 @@ const aiPillColors = [
<!-- 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">
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">02 / Automation</span>
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">{t(locale, "home.features.f2.kicker")}</span>
<h3 class="mt-3 font-display text-2xl font-bold tracking-tight sm:text-3xl">
Integrate with your existing workflows
{t(locale, "home.features.f2.title")}
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
Full REST API with OpenAPI documentation for every tool. Chain tools into automated
pipelines. Batch process thousands of files. Trigger via webhooks and integrate
with your internal systems.
{t(locale, "home.features.f2.body")}
</p>
</div>
<div class="md:order-1">
<pre class="code-block p-5"><span class="code-comment"># Resize an image via the API</span>
<pre class="code-block p-5"><span class="code-comment">{t(locale, "home.features.f2.codeComment1")}</span>
<span class="code-url">$</span> curl <span class="code-flag">-X POST</span> <span class="code-url">localhost:1349/api/v1/tools/image/resize</span> \
<span class="code-flag">-F</span> <span class="code-string">"file=@photo.jpg"</span> \
<span class="code-flag">-F</span> <span class="code-string">'settings=&#123;"width":800&#125;'</span>
<span class="code-comment"># Response</span>
<span class="code-comment">{t(locale, "home.features.f2.codeComment2")}</span>
&#123;
<span class="code-flag">"jobId"</span>: <span class="code-string">"abc123"</span>,
<span class="code-flag">"downloadUrl"</span>: <span class="code-string">"/api/v1/jobs/abc123/download"</span>
@@ -135,14 +138,12 @@ const aiPillColors = [
<!-- 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">
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">03 / Local AI</span>
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">{t(locale, "home.features.f3.kicker")}</span>
<h3 class="mt-3 font-display text-2xl font-bold tracking-tight sm:text-3xl">
AI processing without external APIs
{t(locale, "home.features.f3.title")}
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
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 CPU or NVIDIA CUDA within your own infrastructure.
{t(locale, "home.features.f3.body")}
</p>
</div>
<div class="md:order-2">
@@ -159,14 +160,12 @@ const aiPillColors = [
<!-- 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">
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">04 / Deployment</span>
<span class="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">{t(locale, "home.features.f4.kicker")}</span>
<h3 class="mt-3 font-display text-2xl font-bold tracking-tight sm:text-3xl">
Fits your infrastructure
{t(locale, "home.features.f4.title")}
</h3>
<p class="mt-4 text-base leading-relaxed text-muted">
One Docker Compose stack: app, Postgres, and Redis. Deploy on Kubernetes,
bare metal, or cloud VMs. ARM and x86 architectures. Works in air-gapped and classified
environments with zero internet dependency.
{t(locale, "home.features.f4.body")}
</p>
</div>
<div class="md:order-1">
+47 -34
View File
@@ -1,56 +1,70 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
const year = new Date().getFullYear();
const columns = [
{
title: "Product",
title: t(locale, "footer.col.product"),
links: [
{ label: "Image Tools", href: "/tools/image/" },
{ label: "Video Tools", href: "/tools/video/" },
{ label: "Audio Tools", href: "/tools/audio/" },
{ label: "PDF Tools", href: "/tools/pdf/" },
{ label: "File Tools", href: "/tools/files/" },
{ label: "Image Editor", href: "https://demo.snapotter.com/editor" },
{ label: t(locale, "footer.link.imageTools"), href: localizeHref(locale, "/tools/image/") },
{ label: t(locale, "footer.link.videoTools"), href: localizeHref(locale, "/tools/video/") },
{ label: t(locale, "footer.link.audioTools"), href: localizeHref(locale, "/tools/audio/") },
{ label: t(locale, "footer.link.pdfTools"), href: localizeHref(locale, "/tools/pdf/") },
{ label: t(locale, "footer.link.fileTools"), href: localizeHref(locale, "/tools/files/") },
{ label: t(locale, "footer.link.imageEditor"), href: "https://demo.snapotter.com/editor" },
],
},
{
title: "Solutions",
title: t(locale, "footer.col.solutions"),
links: [
{ label: "Enterprise", href: "/#enterprise" },
{ label: "Self-Hosted", href: "/self-hosted" },
{ label: "Open Source", href: "/#open-source" },
{ label: "Alternatives", href: "/alternatives" },
{ label: "Pricing", href: "/#pricing" },
{ label: t(locale, "footer.link.enterprise"), href: localizeHref(locale, "/#enterprise") },
{ label: t(locale, "footer.link.selfHosted"), href: localizeHref(locale, "/self-hosted") },
{ label: t(locale, "footer.link.openSource"), href: localizeHref(locale, "/#open-source") },
{ label: t(locale, "footer.link.alternatives"), href: localizeHref(locale, "/alternatives") },
{ label: t(locale, "footer.link.pricing"), href: localizeHref(locale, "/#pricing") },
],
},
{
title: "Resources",
title: t(locale, "footer.col.resources"),
links: [
{ label: "Documentation", href: "https://docs.snapotter.com" },
{ label: "API Reference", href: "https://docs.snapotter.com/api/rest" },
{ label: "Docker Hub", href: "https://hub.docker.com/r/snapotter/snapotter" },
{ label: "FAQ", href: "/faq" },
],
},
{
title: "Community",
links: [
{ label: "GitHub", href: "https://github.com/snapotter-hq/snapotter" },
{ label: "Discord", href: "https://discord.gg/hr3s7HPUsr" },
{ label: t(locale, "footer.link.documentation"), href: "https://docs.snapotter.com" },
{ label: t(locale, "footer.link.apiReference"), href: "https://docs.snapotter.com/api/rest" },
{
label: "Contributing",
label: t(locale, "footer.link.dockerHub"),
href: "https://hub.docker.com/r/snapotter/snapotter",
},
{ label: t(locale, "footer.link.faq"), href: localizeHref(locale, "/faq") },
],
},
{
title: t(locale, "footer.col.community"),
links: [
{ label: t(locale, "footer.link.github"), href: "https://github.com/snapotter-hq/snapotter" },
{ label: t(locale, "footer.link.discord"), href: "https://discord.gg/hr3s7HPUsr" },
{
label: t(locale, "footer.link.contributing"),
href: "https://github.com/snapotter-hq/snapotter/blob/main/CONTRIBUTING.md",
},
{ label: "Sponsors", href: "https://github.com/sponsors/snapotter-hq" },
{
label: t(locale, "footer.link.sponsors"),
href: "https://github.com/sponsors/snapotter-hq",
},
],
},
{
title: "Legal",
title: t(locale, "footer.col.legal"),
links: [
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
{ label: "Licensing", href: "/#pricing" },
{ label: t(locale, "footer.link.terms"), href: localizeHref(locale, "/terms") },
{ label: t(locale, "footer.link.privacy"), href: localizeHref(locale, "/privacy") },
{ label: t(locale, "footer.link.licensing"), href: localizeHref(locale, "/#pricing") },
],
},
];
@@ -65,8 +79,7 @@ const columns = [
<span class="font-display text-lg font-bold">SnapOtter</span>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted">
Self-hosted file processing.<br />
Your files, your infrastructure.
{t(locale, "footer.tagline")}
</p>
<div class="mt-4 flex items-center gap-3">
<a
@@ -124,7 +137,7 @@ const columns = [
<div class="section-divider mx-auto mt-12 max-w-6xl"></div>
<div class="mx-auto mt-6 flex max-w-6xl flex-col items-center justify-between gap-3 sm:flex-row">
<p class="text-xs text-muted">
&copy; {year} Chocolate Wafers General Trading Pte. Ltd. All rights reserved.
{t(locale, "footer.copyright", { year })}
</p>
<p class="text-xs text-muted">
contact@snapotter.com
+18 -7
View File
@@ -1,12 +1,23 @@
---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { t } from "@/i18n";
import CategoryCards from "./CategoryCards.astro";
import HeroQuickstart from "./HeroQuickstart.astro";
import HeroSearch from "./HeroSearch.astro";
import TrustSignals from "./TrustSignals.astro";
const trustBadges = ["Self-hosted", "Open source", "Air-gap capable", "Compliance-friendly"];
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
const trustBadges = [
t(locale, "home.hero.badge.selfHosted"),
t(locale, "home.hero.badge.openSource"),
t(locale, "home.hero.badge.airGap"),
t(locale, "home.hero.badge.compliance"),
];
---
<section class="relative overflow-hidden px-6 pt-24 pb-12 md:pt-24 md:pb-16">
@@ -37,24 +48,24 @@ const trustBadges = ["Self-hosted", "Open source", "Air-gap capable", "Complianc
<!-- Headline: names the buying trigger (the network boundary), not "the cloud" -->
<h1 class="animate-fade-up mt-8 font-display text-4xl font-extrabold tracking-tight text-white sm:text-5xl md:text-6xl lg:text-7xl" style="animation-delay: 0.1s; text-shadow: 0 2px 30px rgba(90, 20, 0, 0.28);">
Private file processing for data that can't leave your network.
{t(locale, "home.hero.title")}
</h1>
<!-- Subtitle: capability breadth plus the infrastructure surfaces -->
<p class="animate-fade-up mx-auto mt-6 max-w-2xl text-lg text-white/90 md:text-xl" style="animation-delay: 0.15s; text-shadow: 0 1px 16px rgba(90, 20, 0, 0.22);">
Convert, compress, OCR, transcribe, strip metadata, and run local AI across image, video, audio, PDF, and documents. Through a UI, REST API, and pipelines, on infrastructure you own.
{t(locale, "home.hero.subtitle")}
</p>
<!-- Hero tool search -->
<HeroSearch />
<HeroSearch locale={locale} />
<!-- Category cards -->
<CategoryCards />
<CategoryCards locale={locale} />
<!-- Self-host one-command quickstart + live demo -->
<HeroQuickstart />
<HeroQuickstart locale={locale} />
<!-- Stats strip -->
<TrustSignals />
<TrustSignals locale={locale} />
</div>
</section>
@@ -1,5 +1,11 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { t } from "@/i18n";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
// One-command self-host quickstart shown in the hero, between the category cards
// and the stats. Keep this command in sync with the canonical one in DOCKERHUB.md,
@@ -10,7 +16,7 @@ const DOCKER_CMD =
<div class="animate-fade-up mt-6 text-center" style="animation-delay: 0.24s;">
<p class="mb-3 font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">
Self-host in one command
{t(locale, "home.quickstart.label")}
</p>
<!-- The whole bar is the copy button: click anywhere (or the copy icon) to copy. -->
@@ -19,8 +25,9 @@ const DOCKER_CMD =
id="hero-docker-copy"
class="so-cmd group relative inline-flex max-w-full items-center gap-3 rounded-xl border border-white/10 bg-terminal-bg px-4 py-3.5 text-start font-mono text-[0.8125rem] leading-[1.7] text-terminal-fg"
data-copy={DOCKER_CMD}
aria-label="Copy the Docker run command to your clipboard"
title="Click to copy"
data-copied-status={t(locale, "home.quickstart.copiedStatus")}
aria-label={t(locale, "home.quickstart.copyAria")}
title={t(locale, "home.quickstart.copyTitle")}
>
<span class="shrink-0 select-none font-bold text-terminal-green">$</span>
<span dir="ltr" class="flex min-w-0 flex-1 items-center overflow-x-auto">
@@ -31,7 +38,7 @@ const DOCKER_CMD =
<svg class="copy-idle h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<svg class="copy-done hidden h-4 w-4 text-terminal-green" 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>
<span class="copy-toast pointer-events-none absolute -top-7 end-3 rounded-md bg-foreground px-2 py-0.5 font-body text-[0.6875rem] font-semibold text-white opacity-0 transition-opacity">Copied</span>
<span class="copy-toast pointer-events-none absolute -top-7 end-3 rounded-md bg-foreground px-2 py-0.5 font-body text-[0.6875rem] font-semibold text-white opacity-0 transition-opacity">{t(locale, "home.quickstart.copied")}</span>
</button>
<div class="mt-3.5 flex items-center justify-center">
@@ -42,7 +49,7 @@ const DOCKER_CMD =
class="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-semibold text-foreground transition-all hover:-translate-y-px hover:border-primary hover:bg-primary-subtle"
>
<svg class="h-3.5 w-3.5 text-primary" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>
Try the live demo
{t(locale, "home.quickstart.tryDemo")}
</a>
</div>
@@ -117,7 +124,7 @@ const DOCKER_CMD =
document.body.removeChild(ta);
}
flip(true);
if (status) status.textContent = "Copied to clipboard";
if (status) status.textContent = btn.getAttribute("data-copied-status") ?? "";
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
flip(false);
+21 -8
View File
@@ -3,6 +3,17 @@
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import { t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
import { loadToolStrings } from "@/lib/tool-strings";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
// Tool name/description come from shared i18n (translated in all 21 langs).
const toolStrings = await loadToolStrings(locale);
const MODALITY_META: Record<string, { label: string; color: string }> = {
image: { label: "Image", color: "#E07832" },
@@ -33,9 +44,9 @@ function renderIcon(iconName: string): string {
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"
placeholder={t(locale, "home.hero.searchPlaceholder")}
class="w-full rounded-2xl border border-border bg-surface py-4 pe-4 ps-12 text-base shadow-sm outline-none transition-all placeholder:text-muted focus:border-primary focus:shadow-md"
aria-label={t(locale, "home.hero.searchPlaceholder")}
aria-expanded="false"
aria-controls="hero-search-results"
/>
@@ -49,17 +60,19 @@ function renderIcon(iconName: string): string {
<ul id="hero-search-list" class="max-h-80 overflow-y-auto py-1.5">
{TOOLS.map((tool) => {
const mod = MODALITY_META[tool.modality];
const toolName = toolStrings[tool.id]?.name ?? tool.name;
const toolDescription = toolStrings[tool.id]?.description ?? tool.description;
return (
<li
class="hero-result"
data-name={tool.name.toLowerCase()}
data-desc={tool.description.toLowerCase()}
data-name={toolName.toLowerCase()}
data-desc={toolDescription.toLowerCase()}
data-mod={(mod?.label || "").toLowerCase()}
data-keywords={(tool.keywords ?? []).join(" ")}
hidden
>
<a
href={`/tools/${toolSection(tool)}/${tool.id}/`}
href={localizeHref(locale, `/tools/${toolSection(tool)}/${tool.id}/`)}
class="flex items-center gap-3 px-4 py-2.5 no-underline transition-colors hover:bg-primary-subtle"
>
<svg
@@ -73,7 +86,7 @@ function renderIcon(iconName: string): string {
stroke-linejoin="round"
set:html={renderIcon(tool.icon)}
/>
<span class="flex-1 text-sm font-medium text-foreground">{tool.name}</span>
<span class="flex-1 text-sm font-medium text-foreground">{toolName}</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}`}
@@ -86,7 +99,7 @@ function renderIcon(iconName: string): string {
})}
</ul>
<p id="hero-search-empty" class="hidden px-4 py-3 text-sm text-muted">
No tools match. Try "resize", "convert", or "compress".
{t(locale, "home.hero.searchEmpty")}
</p>
</div>
</div>
@@ -0,0 +1,83 @@
---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
// apps/landing/src/components/LanguageSwitcher.astro
import { LANDING_LOCALES, nativeName, t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
interface Props {
locale: string;
path: string; // the un-prefixed page path, e.g. "/faq"
}
const { locale, path } = Astro.props;
const options = LANDING_LOCALES.map((code) => ({
code,
label: nativeName(code),
href: localizeHref(code, path),
}));
---
<div class="relative" data-language-switcher>
<button
type="button"
class="flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-sm font-medium transition-colors hover:bg-background-alt"
aria-haspopup="listbox"
aria-expanded="false"
aria-label={t(locale, "switcher.label")}
data-switcher-button
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<span>{nativeName(locale)}</span>
</button>
<ul
class="absolute end-0 z-50 mt-2 hidden max-h-80 w-44 overflow-y-auto rounded-xl border border-border bg-surface py-1.5 text-sm shadow-xl"
role="listbox"
data-switcher-menu
>
{options.map((o) => (
<li>
<a
href={o.href}
class="block px-4 py-2 text-foreground no-underline transition-colors hover:bg-primary-subtle"
data-locale={o.code}
aria-current={o.code === locale ? "true" : undefined}
>
{o.label}
</a>
</li>
))}
</ul>
</div>
<script is:inline>
(function () {
document.querySelectorAll("[data-language-switcher]").forEach(function (root) {
var btn = root.querySelector("[data-switcher-button]");
var menu = root.querySelector("[data-switcher-menu]");
if (!btn || !menu) return;
btn.addEventListener("click", function () {
var open = !menu.classList.contains("hidden");
menu.classList.toggle("hidden");
btn.setAttribute("aria-expanded", String(!open));
});
menu.querySelectorAll("a[data-locale]").forEach(function (a) {
a.addEventListener("click", function () {
try {
localStorage.setItem("snapotter:locale", a.getAttribute("data-locale") || "en");
} catch (e) {}
});
});
document.addEventListener("click", function (e) {
if (!root.contains(e.target)) {
menu.classList.add("hidden");
btn.setAttribute("aria-expanded", "false");
}
});
});
})();
</script>
@@ -0,0 +1,83 @@
---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
// apps/landing/src/components/MachineTranslationBanner.astro
import { t } from "@/i18n";
interface Props {
locale: string;
}
const { locale } = Astro.props;
const CONTRIBUTE_URL = "https://docs.snapotter.com/guide/translations";
---
{locale !== "en" && (
<div
class="mt-banner border-b border-border bg-primary-subtle px-4 py-2 text-center text-xs text-foreground"
data-mt-banner
hidden
>
<span>{t(locale, "banner.machineTranslated")} </span>
<a href={CONTRIBUTE_URL} target="_blank" rel="noopener noreferrer" class="font-semibold text-primary-dark underline">
{t(locale, "banner.machineTranslated")}
</a>
<button
type="button"
class="ms-2 font-semibold text-muted hover:text-foreground"
aria-label={t(locale, "banner.dismiss")}
data-mt-dismiss
>
&times;
</button>
</div>
)}
<style is:global>
/* The banner is a fixed bar stacked directly below the 2.0 launch banner and
above the fixed navbar, so it is both visible and its dismiss button is
clickable. It drives --mt-h so the navbar and page content shift down by its
height; --mt-h collapses to 0 on English pages and after dismissal. The
launch banner is temporary, so this composes with --lb-h via calc(). */
:root { --mt-h: 0px; }
.mt-banner {
position: fixed;
top: var(--lb-h, 0px);
left: 0;
right: 0;
z-index: 55; /* above the navbar (z-50), below the launch banner (z-60) */
}
html:has(.mt-banner:not([hidden])) { --mt-h: 36px; }
/* Shift the fixed navbar and page content down by the launch + MT banner
heights. These selectors also live in LaunchBanner.astro; the combined
calc() here supersedes them without fighting the cascade. */
body { padding-top: calc(var(--lb-h, 0px) + var(--mt-h)); }
nav.fixed { top: calc(var(--lb-h, 0px) + var(--mt-h)); }
</style>
<script is:inline>
(function () {
var banner = document.querySelector("[data-mt-banner]");
if (!banner) return;
var KEY = "snapotter:mt-banner";
try {
if (localStorage.getItem(KEY) === "dismissed") {
banner.remove();
return;
}
} catch (e) {}
banner.hidden = false;
var close = banner.querySelector("[data-mt-dismiss]");
if (close) {
close.addEventListener("click", function () {
banner.hidden = true;
try {
localStorage.setItem(KEY, "dismissed");
} catch (e) {}
});
}
})();
</script>
+36 -18
View File
@@ -1,32 +1,49 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import LanguageSwitcher from "@/components/LanguageSwitcher.astro";
import { t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
import { formatCompact, getStarCount } from "@/lib/stats";
interface Props {
locale?: string;
path?: string;
}
const { locale = "en", path = "/" } = Astro.props;
// Fetched at build time; refreshed by the scheduled landing rebuild.
const starCount = formatCompact(await getStarCount());
type NavItem = { label: string; href: string; external?: boolean; desc?: string };
const productLinks: NavItem[] = [
{
label: "Developers",
href: "/self-hosted/file-conversion-api",
desc: "REST API, pipelines, headless",
label: t(locale, "nav.product.developers.label"),
href: localizeHref(locale, "/self-hosted/file-conversion-api"),
desc: t(locale, "nav.product.developers.desc"),
},
{
label: t(locale, "nav.product.selfHosted.label"),
href: localizeHref(locale, "/self-hosted"),
desc: t(locale, "nav.product.selfHosted.desc"),
},
{
label: t(locale, "nav.product.alternatives.label"),
href: localizeHref(locale, "/alternatives"),
desc: t(locale, "nav.product.alternatives.desc"),
},
{ label: "Self-hosted tools", href: "/self-hosted", desc: "Private, on your own infrastructure" },
{ label: "Alternatives", href: "/alternatives", desc: "vs CloudConvert, TinyPNG, Otter.ai" },
];
const links: NavItem[] = [
{ label: "Enterprise", href: "/#enterprise" },
{ label: "Pricing", href: "/#pricing" },
{ label: "Docs", href: "https://docs.snapotter.com", external: true },
{ label: "Talk to a human", href: "/contact" },
{ label: t(locale, "nav.enterprise"), href: localizeHref(locale, "/#enterprise") },
{ label: t(locale, "nav.pricing"), href: localizeHref(locale, "/#pricing") },
{ label: t(locale, "nav.docs"), href: "https://docs.snapotter.com", external: true },
{ label: t(locale, "nav.talkToHuman"), href: localizeHref(locale, "/contact") },
];
---
<nav class="fixed top-0 left-0 right-0 z-50 bg-background/90 backdrop-blur-xl after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-gradient-to-r after:from-transparent after:via-primary/20 after:to-transparent">
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<!-- Logo -->
<a href="/" class="flex shrink-0 items-center gap-2.5">
<a href={localizeHref(locale, "/")} class="flex shrink-0 items-center gap-2.5">
<img src="/logo.png" alt="SnapOtter" class="h-8 w-8" width="32" height="32" />
<span class="font-display text-lg font-bold tracking-tight">SnapOtter</span>
</a>
@@ -40,7 +57,7 @@ const links: NavItem[] = [
class="flex items-center gap-1 whitespace-nowrap text-sm font-medium text-muted transition-colors hover:text-foreground group-focus-within:text-foreground"
aria-haspopup="menu"
>
Product
{t(locale, "nav.product")}
<svg class="h-4 w-4 transition-transform group-hover:rotate-180 group-focus-within:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="absolute left-0 top-full hidden pt-3 group-hover:block group-focus-within:block">
@@ -70,6 +87,7 @@ const links: NavItem[] = [
<!-- Desktop CTAs -->
<div class="hidden shrink-0 items-center gap-3 md:flex">
<LanguageSwitcher locale={locale} path={path} />
<a
href="https://github.com/snapotter-hq/snapotter"
target="_blank"
@@ -81,10 +99,10 @@ const links: NavItem[] = [
<span id="gh-stars" class="text-xs">{starCount}</span>
</a>
<a
href="/contact"
href={localizeHref(locale, "/contact")}
class="whitespace-nowrap rounded-lg border border-border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-background-alt"
>
Book a Demo
{t(locale, "nav.bookDemo")}
</a>
<a
href="https://docs.snapotter.com/guide/getting-started.html"
@@ -92,12 +110,12 @@ const links: NavItem[] = [
rel="noopener noreferrer"
class="btn-primary whitespace-nowrap rounded-lg px-4 py-1.5 text-sm font-semibold"
>
Get Started Free
{t(locale, "nav.getStartedFree")}
</a>
</div>
<!-- Mobile menu toggle (CSS-only) -->
<label for="mobile-menu-toggle" class="cursor-pointer text-muted lg:hidden" aria-label="Toggle menu">
<label for="mobile-menu-toggle" class="cursor-pointer text-muted lg:hidden" aria-label={t(locale, "nav.toggleMenu")}>
<svg class="menu-open h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<svg class="menu-close hidden h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</label>
@@ -119,10 +137,10 @@ const links: NavItem[] = [
))}
<div class="mt-3 flex flex-col gap-2">
<a
href="/contact"
href={localizeHref(locale, "/contact")}
class="block rounded-lg border border-border px-4 py-2 text-center text-sm font-medium"
>
Book a Demo
{t(locale, "nav.bookDemo")}
</a>
<a
href="https://docs.snapotter.com/guide/getting-started.html"
@@ -130,7 +148,7 @@ const links: NavItem[] = [
rel="noopener noreferrer"
class="btn-primary block rounded-lg px-4 py-2 text-center text-sm font-semibold"
>
Get Started Free
{t(locale, "nav.getStartedFree")}
</a>
</div>
</div>
+11 -5
View File
@@ -1,12 +1,18 @@
---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
import { t } from "@/i18n";
import SectionHeading from "./SectionHeading.astro";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
---
<section class="px-6 py-20 md:py-28" id="open-source">
<SectionHeading
title="Open source. Fully auditable."
subtitle="AGPL-3.0 licensed. Your security team can inspect every line. No black boxes, no vendor lock-in."
title={t(locale, "home.openSource.title")}
subtitle={t(locale, "home.openSource.subtitle")}
/>
<div class="reveal mx-auto flex max-w-lg flex-col items-center gap-4 sm:flex-row sm:justify-center">
@@ -17,7 +23,7 @@ import SectionHeading from "./SectionHeading.astro";
class="flex items-center gap-2 rounded-xl border border-border bg-surface px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
<svg class="h-5 w-5" 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>
Star on GitHub
{t(locale, "home.openSource.star")}
</a>
<a
href="https://discord.gg/hr3s7HPUsr"
@@ -26,7 +32,7 @@ import SectionHeading from "./SectionHeading.astro";
class="flex items-center gap-2 rounded-xl border border-border bg-surface px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z"/></svg>
Join Discord
{t(locale, "home.openSource.discord")}
</a>
<a
href="https://github.com/sponsors/snapotter-hq"
@@ -35,7 +41,7 @@ import SectionHeading from "./SectionHeading.astro";
class="flex items-center gap-2 rounded-xl border border-pink-200 bg-surface px-6 py-3 text-sm font-semibold text-pink-600 transition-colors hover:bg-pink-50"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
Sponsor
{t(locale, "home.openSource.sponsor")}
</a>
</div>
</section>
+52 -41
View File
@@ -1,16 +1,53 @@
---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
import SectionHeading from "./SectionHeading.astro";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
const ossFeatures = [
t(locale, "home.pricing.oss.feature.allTools"),
t(locale, "home.pricing.oss.feature.unlimited"),
t(locale, "home.pricing.oss.feature.api"),
t(locale, "home.pricing.oss.feature.pipelines"),
t(locale, "home.pricing.oss.feature.ai"),
t(locale, "home.pricing.oss.feature.multiUser"),
t(locale, "home.pricing.oss.feature.oidc"),
t(locale, "home.pricing.oss.feature.languages"),
t(locale, "home.pricing.oss.feature.deploy"),
t(locale, "home.pricing.oss.feature.arch"),
t(locale, "home.pricing.oss.feature.community"),
t(locale, "home.pricing.oss.feature.license"),
];
const enterpriseFeatures = [
t(locale, "home.pricing.enterprise.feature.everything"),
t(locale, "home.pricing.enterprise.feature.commercial"),
t(locale, "home.pricing.enterprise.feature.sso"),
t(locale, "home.pricing.enterprise.feature.scim"),
t(locale, "home.pricing.enterprise.feature.mfa"),
t(locale, "home.pricing.enterprise.feature.multiTenancy"),
t(locale, "home.pricing.enterprise.feature.permissions"),
t(locale, "home.pricing.enterprise.feature.storage"),
t(locale, "home.pricing.enterprise.feature.webhooks"),
t(locale, "home.pricing.enterprise.feature.support"),
t(locale, "home.pricing.enterprise.feature.deployment"),
t(locale, "home.pricing.enterprise.feature.security"),
];
const checkIcon =
'<svg class="h-4 w-4 shrink-0 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
---
<section class="bg-background-alt px-6 py-20 md:py-28" id="pricing">
<SectionHeading
title="Free for everyone. Enterprise when you need it."
subtitle="Start with the full open source edition. Upgrade when your team needs enterprise controls."
title={t(locale, "home.pricing.title")}
subtitle={t(locale, "home.pricing.subtitle")}
/>
<div class="mx-auto grid max-w-4xl gap-8 md:grid-cols-2">
@@ -20,15 +57,15 @@ const checkIcon =
<div class="relative">
<span class="mb-4 inline-block rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
Most Popular
{t(locale, "home.pricing.oss.badge")}
</span>
<h3 class="font-display text-2xl font-bold">Open Source</h3>
<h3 class="font-display text-2xl font-bold">{t(locale, "home.pricing.oss.name")}</h3>
<div class="mt-2 flex items-baseline gap-1">
<span class="font-display text-4xl font-extrabold">Free</span>
<span class="text-sm text-muted">forever</span>
<span class="font-display text-4xl font-extrabold">{t(locale, "home.pricing.oss.price")}</span>
<span class="text-sm text-muted">{t(locale, "home.pricing.oss.priceSuffix")}</span>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted">
Everything you need for file processing. No limits, no trials, no credit card.
{t(locale, "home.pricing.oss.description")}
</p>
<a
@@ -37,24 +74,11 @@ const checkIcon =
rel="noopener noreferrer"
class="btn-primary mt-6 block rounded-xl px-6 py-3 text-center text-sm font-semibold"
>
Deploy Now
{t(locale, "home.pricing.oss.cta")}
</a>
<ul class="mt-8 space-y-3">
{[
"All 200+ tools across 5 modalities",
"Unlimited usage, no rate limits",
"Full REST API with OpenAPI docs",
"Pipeline automation & batch processing",
"19 local AI tools",
"Multi-user with role-based access",
"OIDC / SSO login (Google, Okta, any provider)",
"21 languages",
"Docker, Kubernetes, bare metal",
"ARM and x86, air-gapped ready",
"Community support (GitHub + Discord)",
"AGPL-3.0 licensed",
].map((feature) => (
{ossFeatures.map((feature) => (
<li class="flex items-start gap-2.5 text-sm">
<Fragment set:html={checkIcon} />
<span>{feature}</span>
@@ -66,36 +90,23 @@ const checkIcon =
<!-- Enterprise -->
<div class="reveal flex flex-col rounded-2xl border border-border bg-surface p-8">
<h3 class="font-display text-2xl font-bold">Enterprise</h3>
<h3 class="font-display text-2xl font-bold">{t(locale, "home.pricing.enterprise.name")}</h3>
<div class="mt-2 flex items-baseline gap-1">
<span class="font-display text-4xl font-extrabold">Custom</span>
<span class="font-display text-4xl font-extrabold">{t(locale, "home.pricing.enterprise.price")}</span>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted">
Commercial license, enterprise security, priority support, and deployment assistance.
{t(locale, "home.pricing.enterprise.description")}
</p>
<a
href="/contact"
href={localizeHref(locale, "/contact")}
class="btn-outline mt-6 block rounded-xl px-6 py-3 text-center text-sm font-semibold"
>
Let's talk
{t(locale, "home.pricing.enterprise.cta")}
</a>
<ul class="mt-8 space-y-3">
{[
"Everything in Open Source",
"Commercial license (no AGPL)",
"SAML SSO + SSO enforcement",
"SCIM provisioning",
"Multi-factor authentication",
"Multi-tenancy",
"Per-tool permissions",
"S3-compatible storage",
"Webhooks + audit log export",
"Priority support with SLA",
"Dedicated deployment help",
"Security review + compliance docs",
].map((feature) => (
{enterpriseFeatures.map((feature) => (
<li class="flex items-start gap-2.5 text-sm">
<Fragment set:html={checkIcon} />
<span>{feature}</span>
+111 -57
View File
@@ -3,76 +3,114 @@
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import { t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
import { loadToolStrings } from "@/lib/tool-strings";
import type { ModalityFilter, ToolSearchItem } from "../lib/tool-search";
import SectionHeading from "./SectionHeading.astro";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
// Tool name/description come from shared i18n (translated in all 21 langs).
const toolStrings = await loadToolStrings(locale);
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
type ToolModality = ToolSearchItem["modality"];
const modalities: { id: ModalityFilter; label: string; count: number }[] = [
{ 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: "all", label: t(locale, "home.toolGrid.filter.all"), count: TOOLS.length },
{
id: "image",
label: t(locale, "home.toolGrid.filter.image"),
count: TOOLS.filter((tool) => tool.modality === "image").length,
},
{
id: "video",
label: t(locale, "home.toolGrid.filter.video"),
count: TOOLS.filter((tool) => tool.modality === "video").length,
},
{
id: "audio",
label: t(locale, "home.toolGrid.filter.audio"),
count: TOOLS.filter((tool) => tool.modality === "audio").length,
},
{
id: "document,file",
label: "PDF & Files",
count: TOOLS.filter((t) => t.modality === "document" || t.modality === "file").length,
label: t(locale, "home.toolGrid.filter.pdfFiles"),
count: TOOLS.filter((tool) => tool.modality === "document" || tool.modality === "file").length,
},
];
const workflows = [
{
id: "optimize",
label: "Optimize delivery",
description: "Compress, resize, convert, and tune assets for fast delivery.",
label: t(locale, "home.toolGrid.workflow.optimize.label"),
description: t(locale, "home.toolGrid.workflow.optimize.description"),
query: "optimize files for web",
icon: "Gauge",
metric: "Media + PDF",
metric: t(locale, "home.toolGrid.workflow.optimize.metric"),
},
{
id: "convert",
label: "Convert formats",
description: "Find exact converters by input, output, or plain-language intent.",
label: t(locale, "home.toolGrid.workflow.convert.label"),
description: t(locale, "home.toolGrid.workflow.convert.description"),
query: "convert files to another format",
icon: "Repeat2",
metric: "100+ routes",
metric: t(locale, "home.toolGrid.workflow.convert.metric"),
},
{
id: "pdf",
label: "Prepare documents",
description: "Merge, split, compress, protect, redact, and normalize PDFs.",
label: t(locale, "home.toolGrid.workflow.pdf.label"),
description: t(locale, "home.toolGrid.workflow.pdf.description"),
query: "prepare pdf documents",
icon: "FileStack",
metric: "PDF workflows",
metric: t(locale, "home.toolGrid.workflow.pdf.metric"),
},
{
id: "metadata",
label: "Clean metadata",
description: "Strip EXIF, GPS, document, media, and archive metadata.",
label: t(locale, "home.toolGrid.workflow.metadata.label"),
description: t(locale, "home.toolGrid.workflow.metadata.description"),
query: "remove metadata",
icon: "ShieldCheck",
metric: "Privacy",
metric: t(locale, "home.toolGrid.workflow.metadata.metric"),
},
{
id: "batch",
label: "Batch operations",
description: "Rename, archive, extract, and process repeatable file sets.",
label: t(locale, "home.toolGrid.workflow.batch.label"),
description: t(locale, "home.toolGrid.workflow.batch.description"),
query: "batch file operations",
icon: "ListChecks",
metric: "Bulk tools",
metric: t(locale, "home.toolGrid.workflow.batch.metric"),
},
{
id: "ai",
label: "Enhance with AI",
description: "Restore, upscale, remove backgrounds, OCR, and transcribe locally.",
label: t(locale, "home.toolGrid.workflow.ai.label"),
description: t(locale, "home.toolGrid.workflow.ai.description"),
query: "ai enhance extract text remove background",
icon: "Sparkles",
metric: "Local AI",
metric: t(locale, "home.toolGrid.workflow.ai.metric"),
},
];
// User-facing strings the client search script renders dynamically. Passed via an
// inlined JSON blob so the script localizes without any runtime fetch.
const toolGridStrings = {
suggested: t(locale, "home.toolGrid.suggested"),
curatedTools: t(locale, "home.toolGrid.curatedTools", { count: "{count}" }),
startingPoints: t(locale, "home.toolGrid.startingPoints", { modality: "{modality}" }),
countOfTools: t(locale, "home.toolGrid.countOfTools", { count: "{count}", total: "{total}" }),
recommended: t(locale, "home.toolGrid.recommended"),
countOfMatches: t(locale, "home.toolGrid.countOfMatches", { count: "{count}", total: "{total}" }),
requestOrRefine: t(locale, "home.toolGrid.requestOrRefine"),
closeToolsOne: t(locale, "home.toolGrid.closeToolsOne", { count: "{count}" }),
closeToolsOther: t(locale, "home.toolGrid.closeToolsOther", { count: "{count}" }),
noConfidentMatch: t(locale, "home.toolGrid.noConfidentMatch"),
};
const workflowAliasesByToolId: Record<string, string[]> = {
"bulk-rename": ["batch file operations", "rename many files"],
"compress-pdf": ["prepare pdf documents", "optimize delivery"],
@@ -127,11 +165,11 @@ function toToolSearchItem(tool: (typeof TOOLS)[number]): ToolSearchItem {
const category = categoryMap.get(tool.category);
return {
id: tool.id,
name: tool.name,
description: tool.description,
name: toolStrings[tool.id]?.name ?? tool.name,
description: toolStrings[tool.id]?.description ?? tool.description,
modality: tool.modality as ToolModality,
category: tool.category,
url: `/tools/${toolSection(tool)}/${tool.id}/`,
url: localizeHref(locale, `/tools/${toolSection(tool)}/${tool.id}/`),
icon: tool.icon,
iconSvg: renderIcon(tool.icon),
color: category?.color ?? "#E07832",
@@ -149,12 +187,13 @@ const defaultTools = defaultToolIds
const safeSearchIndexJson = JSON.stringify(searchIndex).replace(/</g, "\\u003c");
const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u003c");
const safeStringsJson = JSON.stringify(toolGridStrings).replace(/</g, "\\u003c");
---
<section class="bg-background-alt px-6 py-20 md:py-28" id="features">
<SectionHeading
title="One platform. Every file workflow."
subtitle="Search 200+ self-hosted tools by task, format, modality, or workflow."
title={t(locale, "home.toolGrid.title")}
subtitle={t(locale, "home.toolGrid.subtitle")}
/>
<div class="mx-auto max-w-6xl">
@@ -162,7 +201,7 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
<div class="border-b border-border bg-background px-4 py-4 sm:px-5">
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div class="relative">
<label class="sr-only" for="tool-command-search">Search landing tools</label>
<label class="sr-only" for="tool-command-search">{t(locale, "home.toolGrid.searchLabel")}</label>
<svg
class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted"
viewBox="0 0 24 24"
@@ -179,14 +218,14 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
type="text"
inputmode="search"
autocomplete="off"
aria-label="Search landing tools"
placeholder="Workflow or extension"
class="h-13 w-full rounded-lg border border-border bg-surface py-3 pl-12 pr-12 text-base text-foreground outline-none transition-colors placeholder:text-muted focus:border-primary"
aria-label={t(locale, "home.toolGrid.searchLabel")}
placeholder={t(locale, "home.toolGrid.searchPlaceholder")}
class="h-13 w-full rounded-lg border border-border bg-surface py-3 ps-12 pe-12 text-base text-foreground outline-none transition-colors placeholder:text-muted focus:border-primary"
/>
<button
id="tool-command-clear"
type="button"
aria-label="Clear landing tool search"
aria-label={t(locale, "home.toolGrid.clearLabel")}
class="absolute right-3 top-1/2 hidden h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-muted transition-colors hover:bg-background-alt hover:text-foreground"
>
<svg
@@ -205,7 +244,7 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
<div
class="flex flex-wrap items-center gap-1 rounded-lg border border-border bg-surface p-1"
aria-label="Filter tools by modality"
aria-label={t(locale, "home.toolGrid.filterLabel")}
>
{modalities.map((modality) => (
<button
@@ -230,13 +269,13 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
<div class="grid border-b border-border lg:grid-cols-[260px_minmax(0,1fr)]">
<aside class="order-2 border-t border-border bg-background-alt/50 p-4 lg:order-1 lg:border-e lg:border-t-0">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Workflows</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{t(locale, "home.toolGrid.workflowsHeading")}</p>
<button
id="tool-command-reset-workflow"
type="button"
class="hidden rounded-md px-2 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary-subtle"
>
Reset
{t(locale, "home.toolGrid.reset")}
</button>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
@@ -277,13 +316,13 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
<div class="order-1 min-w-0 p-4 sm:p-5 lg:order-2">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tool finder</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{t(locale, "home.toolGrid.finderLabel")}</p>
<h3 class="mt-1 text-xl font-bold tracking-tight text-foreground" id="tool-command-title">
Suggested starting points
{t(locale, "home.toolGrid.suggested")}
</h3>
</div>
<p class="text-sm text-muted" id="tool-command-count" aria-live="polite">
{defaultTools.length} curated tools
{t(locale, "home.toolGrid.curatedTools", { count: defaultTools.length })}
</p>
</div>
@@ -330,9 +369,9 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-lg font-bold tracking-tight text-foreground">No matching tool yet</h3>
<h3 class="text-lg font-bold tracking-tight text-foreground">{t(locale, "home.toolGrid.empty.title")}</h3>
<p class="mt-2 max-w-2xl text-sm leading-relaxed text-muted">
Open a GitHub Discussion with this search prefilled, or browse the closest tools already in SnapOtter.
{t(locale, "home.toolGrid.empty.body")}
</p>
</div>
<a
@@ -342,7 +381,7 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
id="tool-command-request-empty"
class="btn-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold"
>
Request this tool
{t(locale, "home.toolGrid.empty.request")}
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
@@ -357,7 +396,7 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
</a>
</div>
<div class="mt-5 hidden">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Closest existing tools</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{t(locale, "home.toolGrid.closest")}</p>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3" id="tool-command-related"></div>
</div>
</div>
@@ -366,7 +405,7 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
<div class="flex flex-col gap-3 bg-background px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div class="text-sm text-muted">
Missing a workflow?
{t(locale, "home.toolGrid.missingWorkflow")}
<a
href="https://github.com/snapotter-hq/snapotter/discussions/new?category=ideas&title=Tool+request"
target="_blank"
@@ -374,12 +413,12 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
id="tool-command-request"
class="font-semibold text-primary hover:underline"
>
Request a tool
{t(locale, "home.toolGrid.requestTool")}
</a>
and include the input, output, and privacy requirements.
{t(locale, "home.toolGrid.requestSuffix")}
</div>
<a href="/tools" class="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline">
Browse full tool catalog
<a href={localizeHref(locale, "/tools")} class="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline">
{t(locale, "home.toolGrid.browseCatalog")}
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
@@ -398,6 +437,7 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
<script id="tool-command-index" type="application/json" is:inline set:html={safeSearchIndexJson}></script>
<script id="tool-command-default-ids" type="application/json" is:inline set:html={safeDefaultToolIdsJson}></script>
<script id="tool-command-strings" type="application/json" is:inline set:html={safeStringsJson}></script>
</section>
<script>
@@ -411,6 +451,7 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
const indexEl = document.getElementById("tool-command-index");
const defaultIdsEl = document.getElementById("tool-command-default-ids");
const stringsEl = document.getElementById("tool-command-strings");
const searchInput = document.getElementById("tool-command-search") as HTMLInputElement | null;
const clearButton = document.getElementById("tool-command-clear") as HTMLButtonElement | null;
const resetWorkflowButton = document.getElementById(
@@ -431,6 +472,7 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
if (
indexEl?.textContent &&
defaultIdsEl?.textContent &&
stringsEl?.textContent &&
searchInput &&
clearButton &&
resetWorkflowButton &&
@@ -451,6 +493,11 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
const tools = JSON.parse(indexEl.textContent) as ToolSearchItem[];
const defaultToolIds = JSON.parse(defaultIdsEl.textContent) as string[];
const defaultToolIdSet = new Set(defaultToolIds);
const strings = JSON.parse(stringsEl.textContent) as Record<string, string>;
const fill = (template: string, vars: Record<string, string | number>): string =>
template.replace(/\{(\w+)\}/g, (m, name) =>
name in vars ? String(vars[name]) : m,
);
let activeModality: ModalityFilter = "all";
let activeWorkflowQuery = "";
let activeWorkflowLabel = "";
@@ -574,11 +621,13 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
const modalityName = modalityLabels.get(activeModality) ?? "All";
titleNode.textContent =
activeModality === "all" ? "Suggested starting points" : `${modalityName} starting points`;
activeModality === "all"
? strings.suggested
: fill(strings.startingPoints, { modality: modalityName });
countNode.textContent =
activeModality === "all"
? `${defaultTools.length} curated tools`
: `${defaultTools.length} of ${matchingTools.length} tools`;
? fill(strings.curatedTools, { count: defaultTools.length })
: fill(strings.countOfTools, { count: defaultTools.length, total: matchingTools.length });
resultContainer.innerHTML = defaultTools.map((tool) => renderCard(tool)).join("");
resultContainer.classList.remove("hidden");
hideEmptyState();
@@ -600,19 +649,24 @@ const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u
const response = searchTools(tools, { query, modality: activeModality, limit: 12 });
if (response.hasConfidentMatch && response.results.length > 0) {
titleNode.textContent = activeWorkflowLabel || "Recommended tools";
countNode.textContent = `${response.results.length} of ${response.totalMatches} matches`;
titleNode.textContent = activeWorkflowLabel || strings.recommended;
countNode.textContent = fill(strings.countOfMatches, {
count: response.results.length,
total: response.totalMatches,
});
resultContainer.innerHTML = response.results.map((result) => renderCard(result, true)).join("");
resultContainer.classList.remove("hidden");
hideEmptyState();
return;
}
titleNode.textContent = "Request or refine";
titleNode.textContent = strings.requestOrRefine;
countNode.textContent =
response.related.length > 0
? `${response.related.length} close ${response.related.length === 1 ? "tool" : "tools"}`
: "No confident match";
? fill(response.related.length === 1 ? strings.closeToolsOne : strings.closeToolsOther, {
count: response.related.length,
})
: strings.noConfidentMatch;
resultContainer.classList.add("hidden");
emptyContainer.classList.remove("hidden");
relatedContainer.innerHTML = response.related.map((result) => renderCard(result, true)).join("");
+16 -10
View File
@@ -1,7 +1,13 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { t } from "@/i18n";
import { formatCompact, getImagePulls, getStarCount } from "@/lib/stats";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
// Fetched at build time; refreshed by the scheduled landing rebuild.
const starCount = await getStarCount();
const { display: pullsDisplay } = await getImagePulls();
@@ -9,23 +15,23 @@ const { display: pullsDisplay } = await getImagePulls();
const stats = [
{
value: formatCompact(starCount),
label: "GitHub Stars",
sublabel: "Open-source & growing",
label: t(locale, "home.stats.stars.label"),
sublabel: t(locale, "home.stats.stars.sublabel"),
},
{
value: pullsDisplay,
label: "Image Pulls",
sublabel: "Deployed worldwide",
label: t(locale, "home.stats.pulls.label"),
sublabel: t(locale, "home.stats.pulls.sublabel"),
},
{
value: "200+",
label: "Processing Tools",
sublabel: "Across 5 file modalities",
value: t(locale, "home.stats.tools.value"),
label: t(locale, "home.stats.tools.label"),
sublabel: t(locale, "home.stats.tools.sublabel"),
},
{
value: "20+",
label: "Languages",
sublabel: "Speaks your language",
value: t(locale, "home.stats.languages.value"),
label: t(locale, "home.stats.languages.label"),
sublabel: t(locale, "home.stats.languages.sublabel"),
},
];
---