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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
114
frontend/src/config/toolManifest.test.ts
Normal file
114
frontend/src/config/toolManifest.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
601
frontend/src/config/toolManifest.ts
Normal file
601
frontend/src/config/toolManifest.ts
Normal 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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user