mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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={"width":800}'</span>
|
||||
|
||||
<span class="code-comment"># Response</span>
|
||||
<span class="code-comment">{t(locale, "home.features.f2.codeComment2")}</span>
|
||||
{
|
||||
<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">
|
||||
|
||||
@@ -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">
|
||||
© {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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
>
|
||||
×
|
||||
</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user