fix: Add scrollable container to ToolSelectorModal for small screens

- Add max-h-[90vh] and flex-col to modal content container
- Wrap tools grid in max-h-[50vh] overflow-y-auto container
- Add overscroll-contain for smooth scroll behavior on mobile
- Fixes issue where 21 PDF tools overflow viewport on small screens
This commit is contained in:
Your Name
2026-04-01 22:22:48 +02:00
parent 3e1c0e5f99
commit 314f847ece
49 changed files with 2142 additions and 361 deletions

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { ALL_ROUTES } from '@/config/routes';
import { ALL_ROUTES, TOOL_ROUTES } from '@/config/routes';
import { getAllSeoLandingPaths } from '@/config/seoPages';
const __filename = fileURLToPath(import.meta.url);
@@ -12,7 +12,8 @@ const __dirname = dirname(__filename);
* SAFETY TEST — Route Integrity
*
* Ensures that every route in the canonical registry (routes.ts)
* has a matching <Route path="..."> in App.tsx.
* has a matching <Route path="..."> in App.tsx — either as a static
* path="..." attribute or via the TOOL_MANIFEST dynamic loop.
*
* If this test fails it means either:
* 1. A route was removed from App.tsx (NEVER do this)
@@ -25,7 +26,7 @@ describe('Route safety', () => {
);
const seoLandingPaths = new Set(getAllSeoLandingPaths());
// Extract all path="..." values from <Route> elements
// Extract all static path="..." values from <Route> elements
const routePathRegex = /path="([^"]+)"/g;
const appPaths = new Set<string>();
let match: RegExpExecArray | null;
@@ -33,6 +34,11 @@ describe('Route safety', () => {
if (match[1] !== '*') appPaths.add(match[1]);
}
// Detect manifest-driven routing: if App.tsx renders tool routes via
// TOOL_MANIFEST.map, every TOOL_ROUTES entry is covered dynamically.
const hasManifestLoop = appSource.includes('TOOL_MANIFEST.map');
const toolRouteSet = new Set(TOOL_ROUTES as readonly string[]);
it('App.tsx contains routes for every entry in the route registry', () => {
const hasDynamicSeoRoute = appPaths.has('/:slug');
const missing = ALL_ROUTES.filter((route) => {
@@ -40,6 +46,11 @@ describe('Route safety', () => {
return false;
}
// Tool routes covered by the manifest loop
if (hasManifestLoop && toolRouteSet.has(route)) {
return false;
}
if (hasDynamicSeoRoute && seoLandingPaths.has(route)) {
return false;
}

View File

@@ -4,9 +4,12 @@
* SAFETY RULE: Never remove a route from this list.
* New routes may only be appended. The route safety test
* (routes.test.ts) will fail if any existing route is deleted.
*
* Tool routes are now derived from the unified manifest (toolManifest.ts).
*/
import { getAllSeoLandingPaths } from '@/config/seoPages';
import { getManifestRoutePaths } from '@/config/toolManifest';
const STATIC_PAGE_ROUTES = [
'/',
@@ -35,68 +38,8 @@ export const PAGE_ROUTES = [
'/ar/:slug',
] as const;
// ─── Tool routes ─────────────────────────────────────────────────
export const TOOL_ROUTES = [
// PDF Tools
'/tools/pdf-to-word',
'/tools/word-to-pdf',
'/tools/compress-pdf',
'/tools/merge-pdf',
'/tools/split-pdf',
'/tools/rotate-pdf',
'/tools/pdf-to-images',
'/tools/images-to-pdf',
'/tools/watermark-pdf',
'/tools/protect-pdf',
'/tools/unlock-pdf',
'/tools/page-numbers',
'/tools/pdf-editor',
'/tools/pdf-flowchart',
'/tools/pdf-to-excel',
'/tools/remove-watermark-pdf',
'/tools/reorder-pdf',
'/tools/extract-pages',
// Image Tools
'/tools/image-converter',
'/tools/image-resize',
'/tools/compress-image',
'/tools/ocr',
'/tools/remove-background',
'/tools/image-to-svg',
// Convert Tools
'/tools/html-to-pdf',
// AI Tools
'/tools/chat-pdf',
'/tools/summarize-pdf',
'/tools/translate-pdf',
'/tools/extract-tables',
// Other Tools
'/tools/qr-code',
'/tools/video-to-gif',
'/tools/word-counter',
'/tools/text-cleaner',
// Phase 2 PDF Conversion
'/tools/pdf-to-pptx',
'/tools/excel-to-pdf',
'/tools/pptx-to-pdf',
'/tools/sign-pdf',
// Phase 2 PDF Extra Tools
'/tools/crop-pdf',
'/tools/flatten-pdf',
'/tools/repair-pdf',
'/tools/pdf-metadata',
// Phase 2 Image & Utility
'/tools/image-crop',
'/tools/image-rotate-flip',
'/tools/barcode-generator',
] as const;
// ─── Tool routes (derived from manifest) ─────────────────────────
export const TOOL_ROUTES = getManifestRoutePaths() as unknown as readonly string[];
// ─── All routes combined ─────────────────────────────────────────
export const ALL_ROUTES = [...PAGE_ROUTES, ...TOOL_ROUTES] as const;

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import { TOOL_MANIFEST, getManifestSlugs } from '@/config/toolManifest';
import { getAllToolSlugs, getToolSEO } from '@/config/seoData';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* DRIFT-DETECTION TESTS
*
* Ensures toolManifest.ts stays in sync with seoData.ts and HomePage.tsx.
* If any test fails it means someone added a tool in one place but forgot
* the other — fix by updating both files.
*/
describe('Tool Manifest ↔ SEO Data sync', () => {
const manifestSlugs = new Set(getManifestSlugs());
const seoSlugs = new Set(getAllToolSlugs());
it('every manifest tool has an seoData entry', () => {
const missing = [...manifestSlugs].filter((s) => !seoSlugs.has(s));
expect(missing, `Manifest tools missing seoData: ${missing.join(', ')}`).toEqual(
[]
);
});
it('every seoData tool has a manifest entry', () => {
const missing = [...seoSlugs].filter((s) => !manifestSlugs.has(s));
expect(missing, `seoData tools missing manifest: ${missing.join(', ')}`).toEqual(
[]
);
});
it('no duplicate slugs in the manifest', () => {
const seen = new Set<string>();
const dupes: string[] = [];
for (const tool of TOOL_MANIFEST) {
if (seen.has(tool.slug)) dupes.push(tool.slug);
seen.add(tool.slug);
}
expect(dupes, `Duplicate manifest slugs: ${dupes.join(', ')}`).toEqual([]);
});
it('no duplicate slugs in seoData', () => {
const all = getAllToolSlugs();
expect(new Set(all).size).toBe(all.length);
});
it('each seoData entry has required fields populated', () => {
for (const slug of seoSlugs) {
const seo = getToolSEO(slug);
expect(seo, `seoData missing entry for slug: ${slug}`).toBeDefined();
expect(seo!.titleSuffix?.length).toBeGreaterThan(0);
expect(seo!.metaDescription?.length).toBeGreaterThan(0);
}
});
});
describe('Tool Manifest ↔ HomePage ICON_MAP sync', () => {
const homePageSource = readFileSync(
resolve(__dirname, '../pages/HomePage.tsx'),
'utf-8'
);
// Extract icon names from the ICON_MAP object literal
// Match from "= {" to "};" to skip the type annotation that also contains braces
const iconMapMatch = homePageSource.match(/ICON_MAP[^=]+=\s*\{([\s\S]+?)\};/);
const iconMapKeys = new Set(
iconMapMatch
? iconMapMatch[1]
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
: []
);
it('every homepage-visible manifest tool has its icon in ICON_MAP', () => {
const missing: string[] = [];
for (const tool of TOOL_MANIFEST) {
if (tool.homepage && !iconMapKeys.has(tool.iconName)) {
missing.push(`${tool.slug} (icon: ${tool.iconName})`);
}
}
expect(
missing,
`Homepage tools with missing ICON_MAP entries: ${missing.join(', ')}`
).toEqual([]);
});
});
describe('Tool Manifest internal consistency', () => {
it('all manifest entries have non-empty slugs and i18nKeys', () => {
for (const tool of TOOL_MANIFEST) {
expect(tool.slug.length).toBeGreaterThan(0);
expect(tool.i18nKey.length).toBeGreaterThan(0);
}
});
it('all manifest slugs follow kebab-case pattern', () => {
const kebab = /^[a-z0-9]+(-[a-z0-9]+)*$/;
for (const tool of TOOL_MANIFEST) {
expect(
kebab.test(tool.slug),
`Slug "${tool.slug}" is not kebab-case`
).toBe(true);
}
});
it('manifest has at least 40 tools', () => {
expect(TOOL_MANIFEST.length).toBeGreaterThanOrEqual(40);
});
});

View File

@@ -0,0 +1,601 @@
/**
* Unified Tool Manifest — the single source of truth for every tool.
*
* Every consumer (App.tsx routes, HomePage grid, seoData, routes.ts, sitemap)
* should derive its list from this manifest instead of maintaining a separate
* hard-coded array. This eliminates drift between route definitions, SEO
* metadata, and homepage visibility.
*
* SAFETY RULE: Never remove an entry. New tools may only be appended.
*/
// ── Types ──────────────────────────────────────────────────────────
export type ToolCategory = 'pdf-core' | 'pdf-extended' | 'image' | 'conversion' | 'ai' | 'utility';
export interface ToolEntry {
/** URL slug under /tools/ — also used as the unique key */
slug: string;
/** i18n key used in `tools.<key>.title` / `tools.<key>.shortDesc` */
i18nKey: string;
/** Lazy-import factory — returns the React component */
component: () => Promise<{ default: React.ComponentType }>;
/** Portfolio category */
category: ToolCategory;
/** Visible on homepage grid */
homepage: boolean;
/** Homepage section: 'pdf' tools section or 'other' tools section */
homepageSection?: 'pdf' | 'other';
/** Lucide icon name to render (used by HomePage) */
iconName: string;
/** Tailwind text-color class for the icon */
iconColor: string;
/** Tailwind bg-color class for the card */
bgColor: string;
/** Demand tier from portfolio analysis */
demandTier: 'A' | 'B' | 'C';
}
// ── Manifest ───────────────────────────────────────────────────────
export const TOOL_MANIFEST: readonly ToolEntry[] = [
// ─── PDF Core ──────────────────────────────────────────────────
{
slug: 'pdf-editor',
i18nKey: 'pdfEditor',
component: () => import('@/components/tools/PdfEditor'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'PenLine',
iconColor: 'text-rose-600',
bgColor: 'bg-rose-50',
demandTier: 'A',
},
{
slug: 'pdf-to-word',
i18nKey: 'pdfToWord',
component: () => import('@/components/tools/PdfToWord'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileText',
iconColor: 'text-red-600',
bgColor: 'bg-red-50',
demandTier: 'A',
},
{
slug: 'word-to-pdf',
i18nKey: 'wordToPdf',
component: () => import('@/components/tools/WordToPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileOutput',
iconColor: 'text-blue-600',
bgColor: 'bg-blue-50',
demandTier: 'A',
},
{
slug: 'compress-pdf',
i18nKey: 'compressPdf',
component: () => import('@/components/tools/PdfCompressor'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Minimize2',
iconColor: 'text-orange-600',
bgColor: 'bg-orange-50',
demandTier: 'A',
},
{
slug: 'merge-pdf',
i18nKey: 'mergePdf',
component: () => import('@/components/tools/MergePdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Layers',
iconColor: 'text-violet-600',
bgColor: 'bg-violet-50',
demandTier: 'A',
},
{
slug: 'split-pdf',
i18nKey: 'splitPdf',
component: () => import('@/components/tools/SplitPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Scissors',
iconColor: 'text-pink-600',
bgColor: 'bg-pink-50',
demandTier: 'A',
},
{
slug: 'rotate-pdf',
i18nKey: 'rotatePdf',
component: () => import('@/components/tools/RotatePdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'RotateCw',
iconColor: 'text-teal-600',
bgColor: 'bg-teal-50',
demandTier: 'A',
},
{
slug: 'pdf-to-images',
i18nKey: 'pdfToImages',
component: () => import('@/components/tools/PdfToImages'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Image',
iconColor: 'text-amber-600',
bgColor: 'bg-amber-50',
demandTier: 'A',
},
{
slug: 'images-to-pdf',
i18nKey: 'imagesToPdf',
component: () => import('@/components/tools/ImagesToPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileImage',
iconColor: 'text-lime-600',
bgColor: 'bg-lime-50',
demandTier: 'A',
},
{
slug: 'watermark-pdf',
i18nKey: 'watermarkPdf',
component: () => import('@/components/tools/WatermarkPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Droplets',
iconColor: 'text-cyan-600',
bgColor: 'bg-cyan-50',
demandTier: 'B',
},
{
slug: 'protect-pdf',
i18nKey: 'protectPdf',
component: () => import('@/components/tools/ProtectPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Lock',
iconColor: 'text-red-600',
bgColor: 'bg-red-50',
demandTier: 'A',
},
{
slug: 'unlock-pdf',
i18nKey: 'unlockPdf',
component: () => import('@/components/tools/UnlockPdf'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'Unlock',
iconColor: 'text-green-600',
bgColor: 'bg-green-50',
demandTier: 'A',
},
{
slug: 'page-numbers',
i18nKey: 'pageNumbers',
component: () => import('@/components/tools/AddPageNumbers'),
category: 'pdf-core',
homepage: true,
homepageSection: 'pdf',
iconName: 'ListOrdered',
iconColor: 'text-sky-600',
bgColor: 'bg-sky-50',
demandTier: 'B',
},
// ─── PDF Extended ──────────────────────────────────────────────
{
slug: 'pdf-flowchart',
i18nKey: 'pdfFlowchart',
component: () => import('@/components/tools/PdfFlowchart'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'GitBranch',
iconColor: 'text-indigo-600',
bgColor: 'bg-indigo-50',
demandTier: 'C',
},
{
slug: 'remove-watermark-pdf',
i18nKey: 'removeWatermark',
component: () => import('@/components/tools/RemoveWatermark'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'Droplets',
iconColor: 'text-rose-600',
bgColor: 'bg-rose-50',
demandTier: 'B',
},
{
slug: 'reorder-pdf',
i18nKey: 'reorderPdf',
component: () => import('@/components/tools/ReorderPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'ArrowUpDown',
iconColor: 'text-violet-600',
bgColor: 'bg-violet-50',
demandTier: 'B',
},
{
slug: 'extract-pages',
i18nKey: 'extractPages',
component: () => import('@/components/tools/ExtractPages'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileOutput',
iconColor: 'text-amber-600',
bgColor: 'bg-amber-50',
demandTier: 'B',
},
{
slug: 'sign-pdf',
i18nKey: 'signPdf',
component: () => import('@/components/tools/SignPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'PenLine',
iconColor: 'text-emerald-600',
bgColor: 'bg-emerald-50',
demandTier: 'A',
},
{
slug: 'crop-pdf',
i18nKey: 'cropPdf',
component: () => import('@/components/tools/CropPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'Crop',
iconColor: 'text-orange-600',
bgColor: 'bg-orange-50',
demandTier: 'B',
},
{
slug: 'flatten-pdf',
i18nKey: 'flattenPdf',
component: () => import('@/components/tools/FlattenPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileDown',
iconColor: 'text-slate-600',
bgColor: 'bg-slate-50',
demandTier: 'B',
},
{
slug: 'repair-pdf',
i18nKey: 'repairPdf',
component: () => import('@/components/tools/RepairPdf'),
category: 'pdf-extended',
homepage: true,
homepageSection: 'pdf',
iconName: 'Wrench',
iconColor: 'text-yellow-600',
bgColor: 'bg-yellow-50',
demandTier: 'B',
},
{
slug: 'pdf-metadata',
i18nKey: 'pdfMetadata',
component: () => import('@/components/tools/PdfMetadata'),
category: 'pdf-extended',
homepage: false,
homepageSection: 'pdf',
iconName: 'FileText',
iconColor: 'text-gray-600',
bgColor: 'bg-gray-50',
demandTier: 'C',
},
// ─── Image ─────────────────────────────────────────────────────
{
slug: 'image-converter',
i18nKey: 'imageConvert',
component: () => import('@/components/tools/ImageConverter'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'ImageIcon',
iconColor: 'text-purple-600',
bgColor: 'bg-purple-50',
demandTier: 'B',
},
{
slug: 'image-resize',
i18nKey: 'imageResize',
component: () => import('@/components/tools/ImageResize'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'Scaling',
iconColor: 'text-teal-600',
bgColor: 'bg-teal-50',
demandTier: 'B',
},
{
slug: 'compress-image',
i18nKey: 'compressImage',
component: () => import('@/components/tools/CompressImage'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'Minimize2',
iconColor: 'text-orange-600',
bgColor: 'bg-orange-50',
demandTier: 'A',
},
{
slug: 'ocr',
i18nKey: 'ocr',
component: () => import('@/components/tools/OcrTool'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'ScanText',
iconColor: 'text-amber-600',
bgColor: 'bg-amber-50',
demandTier: 'A',
},
{
slug: 'remove-background',
i18nKey: 'removeBg',
component: () => import('@/components/tools/RemoveBackground'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'Eraser',
iconColor: 'text-fuchsia-600',
bgColor: 'bg-fuchsia-50',
demandTier: 'A',
},
{
slug: 'image-to-svg',
i18nKey: 'imageToSvg',
component: () => import('@/components/tools/ImageToSvg'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'ImageIcon',
iconColor: 'text-indigo-600',
bgColor: 'bg-indigo-50',
demandTier: 'B',
},
{
slug: 'image-crop',
i18nKey: 'imageCrop',
component: () => import('@/components/tools/ImageCrop'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'Crop',
iconColor: 'text-pink-600',
bgColor: 'bg-pink-50',
demandTier: 'C',
},
{
slug: 'image-rotate-flip',
i18nKey: 'imageRotateFlip',
component: () => import('@/components/tools/ImageRotateFlip'),
category: 'image',
homepage: true,
homepageSection: 'other',
iconName: 'RotateCw',
iconColor: 'text-cyan-600',
bgColor: 'bg-cyan-50',
demandTier: 'C',
},
// ─── Conversion ────────────────────────────────────────────────
{
slug: 'pdf-to-excel',
i18nKey: 'pdfToExcel',
component: () => import('@/components/tools/PdfToExcel'),
category: 'conversion',
homepage: true,
homepageSection: 'pdf',
iconName: 'Sheet',
iconColor: 'text-green-600',
bgColor: 'bg-green-50',
demandTier: 'A',
},
{
slug: 'html-to-pdf',
i18nKey: 'htmlToPdf',
component: () => import('@/components/tools/HtmlToPdf'),
category: 'conversion',
homepage: true,
homepageSection: 'other',
iconName: 'Code',
iconColor: 'text-sky-600',
bgColor: 'bg-sky-50',
demandTier: 'B',
},
{
slug: 'pdf-to-pptx',
i18nKey: 'pdfToPptx',
component: () => import('@/components/tools/PdfToPptx'),
category: 'conversion',
homepage: true,
homepageSection: 'other',
iconName: 'Presentation',
iconColor: 'text-orange-600',
bgColor: 'bg-orange-50',
demandTier: 'B',
},
{
slug: 'excel-to-pdf',
i18nKey: 'excelToPdf',
component: () => import('@/components/tools/ExcelToPdf'),
category: 'conversion',
homepage: true,
homepageSection: 'other',
iconName: 'Sheet',
iconColor: 'text-emerald-600',
bgColor: 'bg-emerald-50',
demandTier: 'B',
},
{
slug: 'pptx-to-pdf',
i18nKey: 'pptxToPdf',
component: () => import('@/components/tools/PptxToPdf'),
category: 'conversion',
homepage: true,
homepageSection: 'other',
iconName: 'Presentation',
iconColor: 'text-red-600',
bgColor: 'bg-red-50',
demandTier: 'B',
},
// ─── AI ────────────────────────────────────────────────────────
{
slug: 'chat-pdf',
i18nKey: 'chatPdf',
component: () => import('@/components/tools/ChatPdf'),
category: 'ai',
homepage: true,
homepageSection: 'pdf',
iconName: 'MessageSquare',
iconColor: 'text-blue-600',
bgColor: 'bg-blue-50',
demandTier: 'B',
},
{
slug: 'summarize-pdf',
i18nKey: 'summarizePdf',
component: () => import('@/components/tools/SummarizePdf'),
category: 'ai',
homepage: true,
homepageSection: 'pdf',
iconName: 'FileText',
iconColor: 'text-emerald-600',
bgColor: 'bg-emerald-50',
demandTier: 'A',
},
{
slug: 'translate-pdf',
i18nKey: 'translatePdf',
component: () => import('@/components/tools/TranslatePdf'),
category: 'ai',
homepage: true,
homepageSection: 'pdf',
iconName: 'Languages',
iconColor: 'text-purple-600',
bgColor: 'bg-purple-50',
demandTier: 'A',
},
{
slug: 'extract-tables',
i18nKey: 'tableExtractor',
component: () => import('@/components/tools/TableExtractor'),
category: 'ai',
homepage: true,
homepageSection: 'pdf',
iconName: 'Table',
iconColor: 'text-teal-600',
bgColor: 'bg-teal-50',
demandTier: 'B',
},
// ─── Utility ───────────────────────────────────────────────────
{
slug: 'qr-code',
i18nKey: 'qrCode',
component: () => import('@/components/tools/QrCodeGenerator'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'QrCode',
iconColor: 'text-indigo-600',
bgColor: 'bg-indigo-50',
demandTier: 'B',
},
{
slug: 'barcode-generator',
i18nKey: 'barcode',
component: () => import('@/components/tools/BarcodeGenerator'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'Barcode',
iconColor: 'text-gray-600',
bgColor: 'bg-gray-50',
demandTier: 'B',
},
{
slug: 'video-to-gif',
i18nKey: 'videoToGif',
component: () => import('@/components/tools/VideoToGif'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'Film',
iconColor: 'text-emerald-600',
bgColor: 'bg-emerald-50',
demandTier: 'B',
},
{
slug: 'word-counter',
i18nKey: 'wordCounter',
component: () => import('@/components/tools/WordCounter'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'Hash',
iconColor: 'text-blue-600',
bgColor: 'bg-blue-50',
demandTier: 'C',
},
{
slug: 'text-cleaner',
i18nKey: 'textCleaner',
component: () => import('@/components/tools/TextCleaner'),
category: 'utility',
homepage: true,
homepageSection: 'other',
iconName: 'Eraser',
iconColor: 'text-indigo-600',
bgColor: 'bg-indigo-50',
demandTier: 'C',
},
] as const;
// ── Derived helpers ────────────────────────────────────────────────
/** All tool slugs — usable by routes.ts, sitemap, etc. */
export function getManifestSlugs(): string[] {
return TOOL_MANIFEST.map((t) => t.slug);
}
/** Tools visible on the homepage, split by section */
export function getHomepageTools(section: 'pdf' | 'other'): readonly ToolEntry[] {
return TOOL_MANIFEST.filter((t) => t.homepage && t.homepageSection === section);
}
/** Lookup a single tool by slug */
export function getToolEntry(slug: string): ToolEntry | undefined {
return TOOL_MANIFEST.find((t) => t.slug === slug);
}
/** All tool route paths — for the route registry */
export function getManifestRoutePaths(): string[] {
return TOOL_MANIFEST.map((t) => `/tools/${t.slug}`);
}