mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(landing): section-nested tool routes, section index pages, and section-prefixed links
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { TOOLS } from "@snapotter/shared";
|
import { TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
|
|
||||||
function renderIcon(iconName: string): string {
|
function renderIcon(iconName: string): string {
|
||||||
@@ -18,6 +18,7 @@ function renderIcon(iconName: string): string {
|
|||||||
const MODALITIES = [
|
const MODALITIES = [
|
||||||
{
|
{
|
||||||
id: "image",
|
id: "image",
|
||||||
|
section: "image",
|
||||||
label: "Image Tools",
|
label: "Image Tools",
|
||||||
blurb: "Resize, convert, and enhance",
|
blurb: "Resize, convert, and enhance",
|
||||||
icon: "Image",
|
icon: "Image",
|
||||||
@@ -25,6 +26,7 @@ const MODALITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "video",
|
id: "video",
|
||||||
|
section: "video",
|
||||||
label: "Video Tools",
|
label: "Video Tools",
|
||||||
blurb: "Trim, convert, and caption",
|
blurb: "Trim, convert, and caption",
|
||||||
icon: "Video",
|
icon: "Video",
|
||||||
@@ -32,6 +34,7 @@ const MODALITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "audio",
|
id: "audio",
|
||||||
|
section: "audio",
|
||||||
label: "Audio Tools",
|
label: "Audio Tools",
|
||||||
blurb: "Convert, trim, and transcribe",
|
blurb: "Convert, trim, and transcribe",
|
||||||
icon: "Music",
|
icon: "Music",
|
||||||
@@ -39,6 +42,7 @@ const MODALITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "document",
|
id: "document",
|
||||||
|
section: "pdf",
|
||||||
label: "PDF & Documents",
|
label: "PDF & Documents",
|
||||||
blurb: "Merge, split, and convert",
|
blurb: "Merge, split, and convert",
|
||||||
icon: "FileText",
|
icon: "FileText",
|
||||||
@@ -46,6 +50,7 @@ const MODALITIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "file",
|
id: "file",
|
||||||
|
section: "files",
|
||||||
label: "Data & Files",
|
label: "Data & Files",
|
||||||
blurb: "Convert and transform",
|
blurb: "Convert and transform",
|
||||||
icon: "Database",
|
icon: "Database",
|
||||||
@@ -54,7 +59,7 @@ const MODALITIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const cards = MODALITIES.map((m) => {
|
const cards = MODALITIES.map((m) => {
|
||||||
const tools = TOOLS.filter((t) => t.modality === m.id);
|
const tools = TOOLS.filter((t) => toolSection(t) === m.section);
|
||||||
return { ...m, count: tools.length };
|
return { ...m, count: tools.length };
|
||||||
});
|
});
|
||||||
---
|
---
|
||||||
@@ -63,7 +68,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">
|
<div class="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
<a
|
<a
|
||||||
href="/tools"
|
href={`/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"
|
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}`}
|
style={`background-color: ${c.color}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ const columns = [
|
|||||||
{
|
{
|
||||||
title: "Product",
|
title: "Product",
|
||||||
links: [
|
links: [
|
||||||
{ label: "Image Tools", href: "/tools" },
|
{ label: "Image Tools", href: "/tools/image/" },
|
||||||
{ label: "Video Tools", href: "/tools" },
|
{ label: "Video Tools", href: "/tools/video/" },
|
||||||
{ label: "Audio Tools", href: "/tools" },
|
{ label: "Audio Tools", href: "/tools/audio/" },
|
||||||
{ label: "Document Tools", href: "/tools" },
|
{ label: "PDF Tools", href: "/tools/pdf/" },
|
||||||
{ label: "Data Tools", href: "/tools" },
|
{ label: "File Tools", href: "/tools/files/" },
|
||||||
{ label: "Image Editor", href: "https://demo.snapotter.com/editor" },
|
{ label: "Image Editor", href: "https://demo.snapotter.com/editor" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { TOOLS } from "@snapotter/shared";
|
import { TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
|
|
||||||
const MODALITY_META: Record<string, { label: string; color: string }> = {
|
const MODALITY_META: Record<string, { label: string; color: string }> = {
|
||||||
@@ -56,7 +56,7 @@ function renderIcon(iconName: string): string {
|
|||||||
hidden
|
hidden
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={`/tools/${tool.id}`}
|
href={`/tools/${toolSection(tool)}/${tool.id}/`}
|
||||||
class="flex items-center gap-3 px-4 py-2.5 no-underline transition-colors hover:bg-primary-subtle"
|
class="flex items-center gap-3 px-4 py-2.5 no-underline transition-colors hover:bg-primary-subtle"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { CATEGORIES, TOOLS } from "@snapotter/shared";
|
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
import SectionHeading from "./SectionHeading.astro";
|
import SectionHeading from "./SectionHeading.astro";
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ function renderIcon(iconName: string): string {
|
|||||||
const cat = categoryMap.get(tool.category);
|
const cat = categoryMap.get(tool.category);
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={`/tools/${tool.id}`}
|
href={`/tools/${toolSection(tool)}/${tool.id}/`}
|
||||||
class="tool-card flex flex-col items-center rounded-xl border border-border bg-surface px-4 py-5 text-center no-underline transition-all"
|
class="tool-card flex flex-col items-center rounded-xl border border-border bg-surface px-4 py-5 text-center no-underline transition-all"
|
||||||
data-name={tool.name.toLowerCase()}
|
data-name={tool.name.toLowerCase()}
|
||||||
data-desc={tool.description.toLowerCase()}
|
data-desc={tool.description.toLowerCase()}
|
||||||
|
|||||||
+23
-6
@@ -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 * as lucideIcons from "lucide";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
@@ -8,11 +8,18 @@ import { TOOL_SEO } from "@/data/tool-seo";
|
|||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.astro";
|
||||||
|
|
||||||
export function getStaticPaths() {
|
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 { 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 categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
|
||||||
const category = categoryMap.get(tool.category);
|
const category = categoryMap.get(tool.category);
|
||||||
const seo = TOOL_SEO[tool.id];
|
const seo = TOOL_SEO[tool.id];
|
||||||
@@ -47,8 +54,14 @@ const breadcrumbJsonLd = {
|
|||||||
{
|
{
|
||||||
"@type": "ListItem",
|
"@type": "ListItem",
|
||||||
position: 3,
|
position: 3,
|
||||||
|
name: sectionName,
|
||||||
|
item: `https://snapotter.com/tools/${section}/`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 4,
|
||||||
name: tool.name,
|
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",
|
browserRequirements: "Requires JavaScript",
|
||||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||||
license: "https://www.gnu.org/licenses/agpl-3.0.en.html",
|
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 }),
|
...(seo?.features && { featureList: seo.features }),
|
||||||
isPartOf: { "@type": "SoftwareApplication", name: "SnapOtter", url: "https://snapotter.com" },
|
isPartOf: { "@type": "SoftwareApplication", name: "SnapOtter", url: "https://snapotter.com" },
|
||||||
};
|
};
|
||||||
@@ -144,7 +157,7 @@ const allJsonLd = [
|
|||||||
<Base
|
<Base
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
canonical={`https://snapotter.com/tools/${tool.id}`}
|
canonical={`https://snapotter.com/tools/${toolSection(tool)}/${tool.id}/`}
|
||||||
>
|
>
|
||||||
<Fragment slot="head">
|
<Fragment slot="head">
|
||||||
<JsonLd data={allJsonLd} />
|
<JsonLd data={allJsonLd} />
|
||||||
@@ -164,6 +177,10 @@ const allJsonLd = [
|
|||||||
<a href="/tools" class="transition-colors hover:text-foreground">Tools</a>
|
<a href="/tools" class="transition-colors hover:text-foreground">Tools</a>
|
||||||
</li>
|
</li>
|
||||||
<li aria-hidden="true">/</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>
|
<li class="font-medium text-foreground">{tool.name}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -351,7 +368,7 @@ const allJsonLd = [
|
|||||||
const relColor = relCat?.color ?? "#737373";
|
const relColor = relCat?.color ?? "#737373";
|
||||||
return (
|
return (
|
||||||
<a
|
<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"
|
class="group flex flex-col rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
|
||||||
>
|
>
|
||||||
<svg
|
<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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { CATEGORIES, TOOLS } from "@snapotter/shared";
|
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
@@ -68,7 +68,7 @@ const collectionJsonLd = {
|
|||||||
"@type": "ListItem",
|
"@type": "ListItem",
|
||||||
position: i + 1,
|
position: i + 1,
|
||||||
name: tool.name,
|
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">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{group.tools.map((tool) => (
|
{group.tools.map((tool) => (
|
||||||
<a
|
<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"
|
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-name={tool.name.toLowerCase()}
|
||||||
data-desc={tool.description.toLowerCase()}
|
data-desc={tool.description.toLowerCase()}
|
||||||
|
|||||||
Reference in New Issue
Block a user