Files
SnapOtter/apps/landing/src/pages/tools/[...slug].astro
T
SnapOtter 8403222d08 feat(landing): migrate from Next.js to Astro 5
Zero client JS, 165 static pages, self-hosted fonts, Otter Orange
design system. Includes tool SEO data for all 157 tools, enterprise
page, and updated e2e landing tests.
2026-06-14 16:50:20 +08:00

407 lines
16 KiB
Plaintext

---
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import { TOOL_SEO } from "@/data/tool-seo";
import Base from "@/layouts/Base.astro";
export function getStaticPaths() {
return TOOLS.map((tool) => ({ params: { slug: tool.id }, props: { tool } }));
}
const { tool } = Astro.props;
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
const category = categoryMap.get(tool.category);
const seo = TOOL_SEO[tool.id];
const related = TOOLS.filter((t) => t.category === tool.category && t.id !== tool.id).slice(0, 4);
const catColor = category?.color ?? "#737373";
const isAiTool = (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(tool.id);
const toolCount = TOOLS.length;
const title = seo ? `${seo.searchTitle} | SnapOtter` : `${tool.name} | SnapOtter`;
const description = seo?.longDescription ?? tool.description;
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
{ "@type": "ListItem", position: 2, name: "Tools", item: "https://snapotter.com/tools" },
{
"@type": "ListItem",
position: 3,
name: tool.name,
item: `https://snapotter.com/tools/${tool.id}`,
},
],
};
const softwareJsonLd = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: `SnapOtter ${tool.name}`,
description,
applicationCategory: "MultimediaApplication",
operatingSystem: "Linux, macOS, Windows (via Docker)",
browserRequirements: "Requires JavaScript",
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
license: "https://www.gnu.org/licenses/agpl-3.0.en.html",
url: `https://snapotter.com/tools/${tool.id}`,
...(seo?.features && { featureList: seo.features }),
isPartOf: { "@type": "SoftwareApplication", name: "SnapOtter", url: "https://snapotter.com" },
};
const modalityLabel =
tool.modality === "image"
? "image"
: tool.modality === "video"
? "video"
: tool.modality === "audio"
? "audio"
: "file";
const uploadStep =
tool.modality === "image"
? "Drag and drop your image file or click to browse. Supports JPEG, PNG, WebP, AVIF, TIFF, HEIC, and RAW formats."
: tool.modality === "video"
? "Drag and drop your video file or click to browse. Supports MP4, MOV, AVI, MKV, WebM, and more."
: tool.modality === "audio"
? "Drag and drop your audio file or click to browse. Supports MP3, WAV, FLAC, AAC, OGG, and more."
: "Drag and drop your file or click to browse. Supports a wide range of input formats.";
const howToJsonLd = {
"@context": "https://schema.org",
"@type": "HowTo",
name: `How to use ${tool.name} in SnapOtter`,
description,
totalTime: "PT1M",
tool: { "@type": "HowToTool", name: "SnapOtter" },
step: [
{
"@type": "HowToStep",
position: 1,
name: "Deploy SnapOtter",
text: "Run SnapOtter with a single Docker command: docker run -p 1349:1349 snapotter/snapotter",
},
{
"@type": "HowToStep",
position: 2,
name: `Open ${tool.name}`,
text: `Navigate to the ${tool.name} tool in SnapOtter's interface.`,
},
{
"@type": "HowToStep",
position: 3,
name: `Upload your ${modalityLabel}`,
text: uploadStep,
},
{
"@type": "HowToStep",
position: 4,
name: "Adjust settings and process",
text: `Configure ${tool.name} settings to your needs, then click process. Download your result or continue with another tool.`,
},
],
};
const faqJsonLd =
seo?.faqs && seo.faqs.length > 0
? {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: seo.faqs.map((faq) => ({
"@type": "Question",
name: faq.q,
acceptedAnswer: { "@type": "Answer", text: faq.a },
})),
}
: null;
const allJsonLd = [
breadcrumbJsonLd,
softwareJsonLd,
howToJsonLd,
...(faqJsonLd ? [faqJsonLd] : []),
];
---
<Base
title={title}
description={description}
canonical={`https://snapotter.com/tools/${tool.id}`}
>
<Fragment slot="head">
<JsonLd data={allJsonLd} />
</Fragment>
<Navbar />
<main id="main">
<!-- Breadcrumb -->
<nav class="mx-auto max-w-4xl px-6 pt-28 md:pt-36" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-muted">
<li>
<a href="/" class="transition-colors hover:text-foreground">Home</a>
</li>
<li aria-hidden="true">/</li>
<li>
<a href="/tools" class="transition-colors hover:text-foreground">Tools</a>
</li>
<li aria-hidden="true">/</li>
<li class="font-medium text-foreground">{tool.name}</li>
</ol>
</nav>
<!-- Hero -->
<section class="mx-auto max-w-4xl px-6 pt-8 pb-16">
<div class="reveal">
<div class="mb-6 flex items-center gap-3">
<div
class="flex h-14 w-14 items-center justify-center rounded-2xl"
style={`background-color: ${catColor}14`}
>
<svg
class="h-7 w-7"
viewBox="0 0 24 24"
fill="none"
stroke={catColor}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="5" y="5" rx="2" />
</svg>
</div>
<span
class="rounded-full px-3 py-1 text-xs font-semibold"
style={`background-color: ${catColor}14; color: ${catColor}`}
>
{category?.name}
</span>
{isAiTool && (
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
AI-Powered
</span>
)}
</div>
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">
{tool.name}
</h1>
<p class="mt-4 text-lg leading-relaxed text-muted md:text-xl">
{description}
</p>
<div class="mt-8 flex flex-wrap gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
<svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12.5V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h7.5" />
<path d="m22 17-5 5-5-5" />
<path d="M17 22V12" />
</svg>
Deploy with Docker
</a>
<a
href="https://github.com/snapotter-hq/snapotter"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
<svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
View Source
</a>
</div>
</div>
</section>
<!-- Features -->
{seo?.features && seo.features.length > 0 && (
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<div class="mb-6 flex items-center gap-2">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke={catColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
Features
</h2>
</div>
<ul class="grid gap-3 sm:grid-cols-2">
{seo.features.map((feature) => (
<li class="flex gap-3">
<svg class="mt-0.5 h-[18px] w-[18px] shrink-0" viewBox="0 0 24 24" fill="none" stroke={catColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span class="text-sm leading-relaxed">{feature}</span>
</li>
))}
</ul>
</div>
</div>
</section>
)}
<!-- Use Cases -->
{seo?.useCases && seo.useCases.length > 0 && (
<section class="py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
What you can do
</h2>
<ul class="mt-8 grid gap-4 sm:grid-cols-2">
{seo.useCases.map((useCase) => (
<li class="flex gap-3">
<svg class="mt-1 h-4 w-4 shrink-0 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
<span class="text-sm leading-relaxed text-muted">{useCase}</span>
</li>
))}
</ul>
</div>
</div>
</section>
)}
<!-- Self-Hosted Callout -->
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<div class="flex items-start gap-4">
<svg class="mt-1 h-6 w-6 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>
<div>
<h2 class="font-display text-xl font-bold">
{isAiTool
? "AI that runs on your hardware. No cloud APIs, no usage limits."
: "Self-hosted. Your files never leave your network."}
</h2>
<p class="mt-2 text-sm leading-relaxed text-muted">
{isAiTool
? `Unlike cloud AI services, SnapOtter's ${tool.name} runs the ML model directly on your server. Your files are processed locally with no data sent to external APIs. No per-file fees, no rate limits, no privacy concerns. Deploy once with Docker and use it as much as you need.`
: `SnapOtter runs entirely on your own infrastructure. Files processed with ${tool.name} are never uploaded to third-party servers. Deploy a single Docker container and process files with full privacy, no watermarks, and no usage limits. Open source under AGPL-3.0.`}
</p>
</div>
</div>
</div>
</div>
</section>
<!-- FAQs -->
{seo?.faqs && seo.faqs.length > 0 && (
<section class="py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
Frequently asked questions
</h2>
<dl class="mt-8 space-y-6">
{seo.faqs.map((faq) => (
<div class="rounded-xl border border-border bg-surface p-6">
<dt class="text-sm font-semibold">{faq.q}</dt>
<dd class="mt-3 text-sm leading-relaxed text-muted">{faq.a}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
)}
<!-- Related Tools -->
{related.length > 0 && (
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight">
More {category?.name} tools
</h2>
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{related.map((t) => {
const relCat = categoryMap.get(t.category);
const relColor = relCat?.color ?? "#737373";
return (
<a
href={`/tools/${t.id}`}
class="group flex flex-col rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
>
<svg
class="h-6 w-6"
viewBox="0 0 24 24"
fill="none"
stroke={relColor}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="5" y="5" rx="2" />
</svg>
<span class="mt-3 text-sm font-bold">{t.name}</span>
<p class="mt-1.5 text-xs leading-relaxed text-muted">{t.description}</p>
<span class="mt-auto inline-flex items-center gap-1 pt-4 text-xs font-medium text-primary transition-all group-hover:gap-2">
Learn more
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</span>
</a>
);
})}
</div>
</div>
</div>
</section>
)}
<!-- Bottom CTA -->
<section class="py-20">
<div class="mx-auto max-w-4xl px-6 text-center">
<div class="reveal">
<h2 class="font-display text-3xl font-bold tracking-tight">
Ready to try {tool.name}?
</h2>
<p class="mx-auto mt-4 max-w-lg text-muted">
Deploy SnapOtter in under a minute. All {toolCount} tools included. Open source and free forever.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
Get Started
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</a>
<a
href="/#pricing"
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
View Pricing
</a>
</div>
</div>
</div>
</section>
</main>
<Footer />
</Base>