feat(landing): section-nested tool routes, section index pages, and section-prefixed links

This commit is contained in:
SnapOtter
2026-06-20 23:53:54 +08:00
parent 5dbbac6c43
commit f158be6aaa
7 changed files with 105 additions and 21 deletions
@@ -1,5 +1,5 @@
---
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
@@ -8,11 +8,18 @@ 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 } }));
return TOOLS.map((tool) => ({
params: { section: toolSection(tool), tool: tool.id },
props: { tool },
}));
}
const { tool } = Astro.props;
const section = toolSection(tool);
const sectionInfo = SECTIONS.find((s) => s.id === section);
const sectionName = sectionInfo?.name ?? section;
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
const category = categoryMap.get(tool.category);
const seo = TOOL_SEO[tool.id];
@@ -47,8 +54,14 @@ const breadcrumbJsonLd = {
{
"@type": "ListItem",
position: 3,
name: sectionName,
item: `https://snapotter.com/tools/${section}/`,
},
{
"@type": "ListItem",
position: 4,
name: tool.name,
item: `https://snapotter.com/tools/${tool.id}`,
item: `https://snapotter.com/tools/${section}/${tool.id}/`,
},
],
};
@@ -63,7 +76,7 @@ const softwareJsonLd = {
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}`,
url: `https://snapotter.com/tools/${toolSection(tool)}/${tool.id}/`,
...(seo?.features && { featureList: seo.features }),
isPartOf: { "@type": "SoftwareApplication", name: "SnapOtter", url: "https://snapotter.com" },
};
@@ -144,7 +157,7 @@ const allJsonLd = [
<Base
title={title}
description={description}
canonical={`https://snapotter.com/tools/${tool.id}`}
canonical={`https://snapotter.com/tools/${toolSection(tool)}/${tool.id}/`}
>
<Fragment slot="head">
<JsonLd data={allJsonLd} />
@@ -164,6 +177,10 @@ const allJsonLd = [
<a href="/tools" class="transition-colors hover:text-foreground">Tools</a>
</li>
<li aria-hidden="true">/</li>
<li>
<a href={`/tools/${section}/`} class="transition-colors hover:text-foreground">{sectionName}</a>
</li>
<li aria-hidden="true">/</li>
<li class="font-medium text-foreground">{tool.name}</li>
</ol>
</nav>
@@ -351,7 +368,7 @@ const allJsonLd = [
const relColor = relCat?.color ?? "#737373";
return (
<a
href={`/tools/${t.id}`}
href={`/tools/${toolSection(t)}/${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
@@ -0,0 +1,62 @@
---
import { SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
import Base from "@/layouts/Base.astro";
import Navbar from "@/components/Navbar.astro";
import Footer from "@/components/Footer.astro";
export function getStaticPaths() {
return SECTIONS.map((s) => ({
params: { section: s.id },
props: { section: s, tools: TOOLS.filter((t) => toolSection(t) === s.id) },
}));
}
const { section, tools } = Astro.props;
const canonical = `https://snapotter.com/tools/${section.id}/`;
const title = `${section.name} Tools | SnapOtter`;
---
<Base
canonical={canonical}
title={title}
description={`Browse SnapOtter's ${section.name.toLowerCase()} tools.`}
>
<Navbar />
<main id="main">
<nav class="mx-auto max-w-6xl 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">{section.name}</li>
</ol>
</nav>
<section class="mx-auto max-w-6xl px-6 pt-8 pb-16">
<div class="reveal">
<h1 class="font-display text-3xl font-bold">{section.name} Tools</h1>
<ul class="mt-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool) => (
<li>
<a
class="card-hover block rounded-lg border border-border p-4"
href={`/tools/${section.id}/${tool.id}/`}
>
<span class="font-medium">{tool.name}</span>
<span class="mt-1 block text-sm text-muted">{tool.description}</span>
</a>
</li>
))}
</ul>
</div>
</section>
</main>
<Footer />
</Base>
+3 -3
View File
@@ -1,5 +1,5 @@
---
import { CATEGORIES, TOOLS } from "@snapotter/shared";
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
@@ -68,7 +68,7 @@ const collectionJsonLd = {
"@type": "ListItem",
position: i + 1,
name: tool.name,
url: `https://snapotter.com/tools/${tool.id}`,
url: `https://snapotter.com/tools/${toolSection(tool)}/${tool.id}/`,
})),
},
};
@@ -185,7 +185,7 @@ const collectionJsonLd = {
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{group.tools.map((tool) => (
<a
href={`/tools/${tool.id}`}
href={`/tools/${toolSection(tool)}/${tool.id}/`}
class="tool-item group flex items-start gap-4 rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
data-name={tool.name.toLowerCase()}
data-desc={tool.description.toLowerCase()}