mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user