Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/pages/category/[category].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
import { ChevronRight } from 'lucide-react';
import Base from '@/layouts/Base.astro';
import { tools } from '@/registry/tools';
import { categories, categoryColors, categoryDescriptions, categorySlug } from '@/registry/categories';
import { SITE_URL, SITE_NAME } from '@/config';
import type { Category } from '@/types/tool';

export function getStaticPaths() {
return categories
.filter(category => tools.some(t => t.category === category && !t.desktopOnly))
.map(category => ({ params: { category: categorySlug(category) }, props: { category } }));
}

const { category } = Astro.props as { category: Category };
const categoryTools = tools.filter(t => t.category === category && !t.desktopOnly);
const pageTitle = `${category} Tools`;
const description = categoryDescriptions[category];
const pageUrl = new URL(`/category/${categorySlug(category)}`, SITE_URL).href;

const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' },
{ '@type': 'ListItem', position: 2, name: pageTitle, item: pageUrl },
],
};
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${category} tools on ${SITE_NAME}`,
numberOfItems: categoryTools.length,
itemListElement: categoryTools.map((t, i) => ({
'@type': 'ListItem',
position: i + 1,
name: t.name,
url: new URL(t.route, SITE_URL).href,
})),
};
---

<Base
title={`${pageTitle} — Free & Private`}
description={description}
keywords={[`${category.toLowerCase()} tools`, 'free', 'online', 'browser', 'privacy', 'no upload']}
jsonLd={[breadcrumbJsonLd, itemListJsonLd]}
>
<main class="page-container py-8">
<nav aria-label="Breadcrumb" class="mb-4">
<ol class="flex items-center gap-1 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<li>
<a href="/" class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal">Home</a>
</li>
<li aria-hidden="true"><ChevronRight className="h-3.5 w-3.5" /></li>
<li aria-current="page" class="text-foreground">{pageTitle}</li>
</ol>
</nav>

<header class="mb-8">
<div class="mb-2 flex items-center gap-2">
<span class={`inline-block h-5 w-5 border-2 border-border ${categoryColors[category]}`} />
<h1 class="text-3xl font-bold uppercase tracking-tight">{pageTitle}</h1>
<span class="text-sm font-bold text-muted-foreground">({categoryTools.length})</span>
</div>
<p class="max-w-3xl text-muted-foreground">{description}</p>
</header>

<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{categoryTools.map(tool => {
const Icon = tool.icon;
return (
<a href={tool.route} class="group flex items-start gap-3 border-2 border-border bg-muted p-4 shadow-brutal press-brutal">
<span class={`mt-0.5 border-2 border-border p-2 text-black ${categoryColors[tool.category]}`}>
<Icon className="h-5 w-5" />
</span>
<span class="min-w-0">
<span class="block font-bold">{tool.name}</span>
<span class="block text-sm text-muted-foreground">{tool.summary}</span>
</span>
</a>
);
})}
</div>
</main>
</Base>
115 changes: 102 additions & 13 deletions src/pages/tools/[tool].astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Base from '@/layouts/Base.astro';
import ToolHost from '@/islands/ToolHost';
import ShareButton from '@/islands/share/ShareButton';
import { tools, getToolById } from '@/registry/tools';
import { getToolSeo } from '@/registry/tool-seo';
import { categoryColors, categorySlug } from '@/registry/categories';
import { SITE_URL, SITE_NAME } from '@/config';

export function getStaticPaths() {
Expand All @@ -16,14 +18,25 @@ export function getStaticPaths() {
const { toolId } = Astro.props;
const tool = getToolById(toolId)!;
const toolUrl = new URL(tool.route, SITE_URL).href;
// Sharper, keyword-aware description for search results.
const metaDescription = `${tool.summary}. Free, on-device, and private — runs entirely in your browser with no uploads. Part of ${SITE_NAME}.`;
const seo = getToolSeo(tool.id);

// Authored copy where present; otherwise fall back to the registry summary.
const lead = seo?.intro ?? tool.summary;
const metaDescription =
seo?.description ??
`${tool.summary}. Free, on-device, and private — runs entirely in your browser with no uploads. Part of ${SITE_NAME}.`;

// Category hub (breadcrumb + related links).
const catPath = `/category/${categorySlug(tool.category)}`;
const relatedTools = tools
.filter(t => t.category === tool.category && t.id !== tool.id && !t.desktopOnly)
.slice(0, 6);

const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: tool.name,
description: tool.summary,
description: metaDescription,
applicationCategory: 'UtilitiesApplication',
operatingSystem: 'Any (web browser)',
url: toolUrl,
Expand All @@ -37,26 +50,47 @@ const breadcrumbJsonLd = {
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' },
{ '@type': 'ListItem', position: 2, name: tool.name, item: toolUrl },
{ '@type': 'ListItem', position: 2, name: `${tool.category} Tools`, item: new URL(catPath, SITE_URL).href },
{ '@type': 'ListItem', position: 3, name: tool.name, item: toolUrl },
],
};
const jsonLd: Record<string, unknown>[] = [softwareJsonLd, breadcrumbJsonLd];
if (seo?.howTo && seo.howTo.length > 0) {
jsonLd.push({
'@context': 'https://schema.org',
'@type': 'HowTo',
name: `How to use ${tool.name}`,
step: seo.howTo.map((text, i) => ({ '@type': 'HowToStep', position: i + 1, text })),
});
}
if (seo?.faqs && seo.faqs.length > 0) {
jsonLd.push({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: seo.faqs.map(f => ({
'@type': 'Question',
name: f.q,
acceptedAnswer: { '@type': 'Answer', text: f.a },
})),
});
}
---

<Base
title={tool.name}
title={seo?.title ?? tool.name}
description={metaDescription}
keywords={tool.keywords}
jsonLd={[softwareJsonLd, breadcrumbJsonLd]}
jsonLd={jsonLd}
>
<main class="page-container py-8">
<nav aria-label="Breadcrumb" class="mb-4">
<ol class="flex items-center gap-1 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<ol class="flex flex-wrap items-center gap-1 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<li>
<a href="/" class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal">Home</a>
</li>
<li aria-hidden="true"><ChevronRight className="h-3.5 w-3.5" /></li>
<li>
<a
href="/"
class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal"
>Home</a
>
<a href={catPath} class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal">{tool.category}</a>
</li>
<li aria-hidden="true"><ChevronRight className="h-3.5 w-3.5" /></li>
<li aria-current="page" class="text-foreground">{tool.name}</li>
Expand All @@ -75,8 +109,63 @@ const breadcrumbJsonLd = {
</div>
<ShareButton client:idle url={toolUrl} title={`${tool.name} — ${SITE_NAME}`} text={tool.summary} />
</div>
<p class="text-muted-foreground">{tool.summary}</p>
<p class="max-w-3xl text-muted-foreground">{lead}</p>
</div>

<ToolHost client:load toolId={toolId} />

{(seo?.howTo?.length || seo?.faqs?.length || relatedTools.length > 0) && (
<div class="mt-12 space-y-10 border-t-2 border-border pt-8">
{seo?.howTo && seo.howTo.length > 0 && (
<section aria-labelledby="howto-heading">
<h2 id="howto-heading" class="mb-4 text-xl font-bold uppercase tracking-tight">How to use {tool.name}</h2>
<ol class="max-w-3xl space-y-2">
{seo.howTo.map((step, i) => (
<li class="flex gap-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center border-2 border-border bg-accent text-sm font-bold text-accent-foreground">{i + 1}</span>
<span class="text-muted-foreground">{step}</span>
</li>
))}
</ol>
</section>
)}

{seo?.faqs && seo.faqs.length > 0 && (
<section aria-labelledby="faq-heading">
<h2 id="faq-heading" class="mb-4 text-xl font-bold uppercase tracking-tight">Frequently asked questions</h2>
<div class="max-w-3xl space-y-3">
{seo.faqs.map(f => (
<details class="border-2 border-border bg-muted p-4">
<summary class="cursor-pointer font-bold">{f.q}</summary>
<p class="mt-2 text-muted-foreground">{f.a}</p>
</details>
))}
</div>
</section>
)}

{relatedTools.length > 0 && (
<section aria-labelledby="related-heading">
<h2 id="related-heading" class="mb-4 text-xl font-bold uppercase tracking-tight">Related {tool.category} tools</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{relatedTools.map(rt => {
const Icon = rt.icon;
return (
<a href={rt.route} class="group flex items-start gap-3 border-2 border-border bg-muted p-4 shadow-brutal-sm press-brutal">
<span class={`mt-0.5 border-2 border-border p-2 text-black ${categoryColors[rt.category]}`}>
<Icon className="h-4 w-4" />
</span>
<span class="min-w-0">
<span class="block font-bold">{rt.name}</span>
<span class="block text-sm text-muted-foreground">{rt.summary}</span>
</span>
</a>
);
})}
</div>
</section>
)}
</div>
)}
</main>
</Base>
18 changes: 18 additions & 0 deletions src/registry/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,21 @@ export const categoryColors: Record<Category, string> = {
export const categoryNotes: Partial<Record<Category, string>> = {
Network: 'These tools connect two devices directly (peer-to-peer). By default a minimal signaling server only introduces the devices — your media and files never pass through it — and you can switch to a fully serverless manual mode or bring your own STUN/TURN servers.',
};

/** URL slug for a category hub page, e.g. 'PDF' → 'pdf'. */
export function categorySlug(category: Category): string {
return category.toLowerCase();
}

/** SEO lead copy for each category hub page (unique, keyword-aware). */
export const categoryDescriptions: Record<Category, string> = {
Dev: 'Free developer utilities that run entirely in your browser — format and validate JSON, encode Base64 and URLs, hash and diff text, generate UUIDs, and more. Nothing is uploaded.',
PDF: 'Work with PDFs privately in your browser — merge, split, compress, convert, repair, protect and edit. Your documents never leave your device, so even confidential files stay safe.',
Image: 'Edit and convert images on your device — resize, crop, compress, convert formats, remove backgrounds, upscale, blur faces, extract text and more. No uploads, no watermarks.',
Files: 'Everyday file utilities that keep your data local — archive, extract, encrypt and inspect files right in the browser with nothing sent to a server.',
Draw: 'Simple drawing and diagramming tools that run in your browser — sketch, annotate and create diagrams without an account or any upload.',
Media: 'Private audio and video utilities — convert, trim, record and transcribe media entirely on your device using on-device processing. Your recordings never leave your browser.',
Network: 'Peer-to-peer tools that connect two devices directly to transfer files or communicate — your data flows device to device, not through a server.',
Maps: 'Open-source mapping tools — convert coordinates, explore and export maps, and view GeoJSON, GPX and KML files. Built on open map data, running in your browser.',
Playground: 'Interactive playgrounds and experiments to explore and learn — all running client-side in your browser.',
};
Loading
Loading