seo(frontend): strengthen indexing and internal linking
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ const today = new Date().toISOString().slice(0, 10);
|
|||||||
const seoConfig = JSON.parse(
|
const seoConfig = JSON.parse(
|
||||||
await readFile(path.join(frontendRoot, 'src', 'seo', 'seoData.json'), 'utf8')
|
await readFile(path.join(frontendRoot, 'src', 'seo', 'seoData.json'), 'utf8')
|
||||||
);
|
);
|
||||||
|
const routeRegistrySource = await readFile(path.join(frontendRoot, 'src', 'config', 'routes.ts'), 'utf8');
|
||||||
|
|
||||||
const staticPages = [
|
const staticPages = [
|
||||||
{ path: '/', changefreq: 'daily', priority: '1.0' },
|
{ path: '/', changefreq: 'daily', priority: '1.0' },
|
||||||
@@ -52,6 +53,7 @@ const toolRoutePriorities = new Map([
|
|||||||
['image-resize', '0.8'],
|
['image-resize', '0.8'],
|
||||||
['compress-image', '0.8'],
|
['compress-image', '0.8'],
|
||||||
['remove-background', '0.8'],
|
['remove-background', '0.8'],
|
||||||
|
['image-to-svg', '0.8'],
|
||||||
['image-crop', '0.7'],
|
['image-crop', '0.7'],
|
||||||
['image-rotate-flip', '0.7'],
|
['image-rotate-flip', '0.7'],
|
||||||
['ocr', '0.8'],
|
['ocr', '0.8'],
|
||||||
@@ -70,6 +72,10 @@ const toolRoutePriorities = new Map([
|
|||||||
['barcode-generator', '0.7'],
|
['barcode-generator', '0.7'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function extractToolSlugs(source) {
|
||||||
|
return [...source.matchAll(/'\/tools\/([^']+)'/g)].map((match) => match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
function extractBlogSlugs(source) {
|
function extractBlogSlugs(source) {
|
||||||
return [...source.matchAll(/slug:\s*'([^']+)'/g)].map((match) => match[1]);
|
return [...source.matchAll(/slug:\s*'([^']+)'/g)].map((match) => match[1]);
|
||||||
}
|
}
|
||||||
@@ -78,32 +84,47 @@ function makeUrlTag({ loc, changefreq, priority }) {
|
|||||||
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${today}</lastmod>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>\n </url>`;
|
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${today}</lastmod>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>\n </url>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dedupeEntries(entries) {
|
||||||
|
const seen = new Set();
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
if (seen.has(entry.loc)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(entry.loc);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const blogSource = await readFile(path.join(frontendRoot, 'src', 'content', 'blogArticles.ts'), 'utf8');
|
const blogSource = await readFile(path.join(frontendRoot, 'src', 'content', 'blogArticles.ts'), 'utf8');
|
||||||
const blogSlugs = extractBlogSlugs(blogSource);
|
const blogSlugs = extractBlogSlugs(blogSource);
|
||||||
|
const toolSlugs = extractToolSlugs(routeRegistrySource);
|
||||||
|
|
||||||
const sitemapEntries = [
|
const sitemapEntries = dedupeEntries([
|
||||||
...staticPages.map((page) =>
|
...staticPages.map((page) => ({
|
||||||
makeUrlTag({ loc: `${siteOrigin}${page.path}`, changefreq: page.changefreq, priority: page.priority })
|
loc: `${siteOrigin}${page.path}`,
|
||||||
),
|
changefreq: page.changefreq,
|
||||||
...blogSlugs.map((slug) =>
|
priority: page.priority,
|
||||||
makeUrlTag({ loc: `${siteOrigin}/blog/${slug}`, changefreq: 'monthly', priority: '0.6' })
|
})),
|
||||||
),
|
...blogSlugs.map((slug) => ({
|
||||||
...[...toolRoutePriorities.entries()].map(([slug, priority]) =>
|
loc: `${siteOrigin}/blog/${slug}`,
|
||||||
makeUrlTag({ loc: `${siteOrigin}/tools/${slug}`, changefreq: 'weekly', priority })
|
changefreq: 'monthly',
|
||||||
),
|
priority: '0.6',
|
||||||
...seoConfig.toolPageSeeds.map((page) =>
|
})),
|
||||||
makeUrlTag({ loc: `${siteOrigin}/${page.slug}`, changefreq: 'weekly', priority: '0.88' })
|
...toolSlugs.map((slug) => ({
|
||||||
),
|
loc: `${siteOrigin}/tools/${slug}`,
|
||||||
...seoConfig.toolPageSeeds.map((page) =>
|
changefreq: 'weekly',
|
||||||
makeUrlTag({ loc: `${siteOrigin}/ar/${page.slug}`, changefreq: 'weekly', priority: '0.8' })
|
priority: toolRoutePriorities.get(slug) || '0.6',
|
||||||
),
|
})),
|
||||||
...seoConfig.collectionPageSeeds.map((page) =>
|
...seoConfig.toolPageSeeds.flatMap((page) => ([
|
||||||
makeUrlTag({ loc: `${siteOrigin}/${page.slug}`, changefreq: 'weekly', priority: '0.82' })
|
{ loc: `${siteOrigin}/${page.slug}`, changefreq: 'weekly', priority: '0.88' },
|
||||||
),
|
{ loc: `${siteOrigin}/ar/${page.slug}`, changefreq: 'weekly', priority: '0.8' },
|
||||||
...seoConfig.collectionPageSeeds.map((page) =>
|
])),
|
||||||
makeUrlTag({ loc: `${siteOrigin}/ar/${page.slug}`, changefreq: 'weekly', priority: '0.74' })
|
...seoConfig.collectionPageSeeds.flatMap((page) => ([
|
||||||
),
|
{ loc: `${siteOrigin}/${page.slug}`, changefreq: 'weekly', priority: '0.82' },
|
||||||
];
|
{ loc: `${siteOrigin}/ar/${page.slug}`, changefreq: 'weekly', priority: '0.74' },
|
||||||
|
])),
|
||||||
|
]).map((entry) => makeUrlTag(entry));
|
||||||
|
|
||||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapEntries.join('\n')}\n</urlset>\n`;
|
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapEntries.join('\n')}\n</urlset>\n`;
|
||||||
|
|
||||||
|
|||||||
47
frontend/src/components/seo/BreadcrumbNav.tsx
Normal file
47
frontend/src/components/seo/BreadcrumbNav.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbNavProps {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BreadcrumbNav({ items, className = '' }: BreadcrumbNavProps) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className={className}>
|
||||||
|
<ol className="flex flex-wrap items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={`${item.label}-${index}`} className="flex items-center gap-2">
|
||||||
|
{item.to && !isLast ? (
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
className="transition-colors hover:text-primary-600 dark:hover:text-primary-400"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={isLast ? 'font-medium text-slate-700 dark:text-slate-200' : ''}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLast ? <ChevronRight className="h-4 w-4 text-slate-300 dark:text-slate-600" /> : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getToolSEO } from '@/config/seoData';
|
import { getInternalLinkToolSlugs, getToolSEO } from '@/config/seoData';
|
||||||
|
|
||||||
interface RelatedToolsProps {
|
interface RelatedToolsProps {
|
||||||
currentSlug: string;
|
currentSlug: string;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
@@ -14,12 +15,12 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
Utility: 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400',
|
Utility: 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RelatedTools({ currentSlug }: RelatedToolsProps) {
|
export default function RelatedTools({ currentSlug, limit = 8 }: RelatedToolsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentTool = getToolSEO(currentSlug);
|
const currentTool = getToolSEO(currentSlug);
|
||||||
if (!currentTool) return null;
|
if (!currentTool) return null;
|
||||||
|
|
||||||
const relatedTools = currentTool.relatedSlugs
|
const relatedTools = getInternalLinkToolSlugs(currentSlug, limit)
|
||||||
.map((slug) => getToolSEO(slug))
|
.map((slug) => getToolSEO(slug))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export default function RelatedTools({ currentSlug }: RelatedToolsProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-slate-800 group-hover:text-primary-600 dark:text-slate-200 dark:group-hover:text-primary-400">
|
<h3 className="font-semibold text-slate-800 group-hover:text-primary-600 dark:text-slate-200 dark:group-hover:text-primary-400">
|
||||||
{tool!.titleSuffix.replace(/^Free Online\s*/, '').replace(/\s*—.*$/, '')}
|
{t(`tools.${tool!.i18nKey}.title`)}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${CATEGORY_COLORS[tool!.category] || ''}`}
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${CATEGORY_COLORS[tool!.category] || ''}`}
|
||||||
@@ -48,7 +49,7 @@ export default function RelatedTools({ currentSlug }: RelatedToolsProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400 line-clamp-2">
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400 line-clamp-2">
|
||||||
{tool!.metaDescription}
|
{t(`tools.${tool!.i18nKey}.shortDesc`)}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function SEOHead({ title, description, keywords, path, type = 'we
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{fullTitle}</title>
|
<title>{fullTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
||||||
{keywords ? <meta name="keywords" content={keywords} /> : null}
|
{keywords ? <meta name="keywords" content={keywords} /> : null}
|
||||||
<link rel="canonical" href={canonicalUrl} />
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
{languageAlternates.map((alternate) => (
|
{languageAlternates.map((alternate) => (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getToolSEO } from '@/config/seoData';
|
import { getPopularToolSlugs, getToolSEO } from '@/config/seoData';
|
||||||
|
|
||||||
interface SuggestedToolsProps {
|
interface SuggestedToolsProps {
|
||||||
currentSlug: string;
|
currentSlug: string;
|
||||||
@@ -24,7 +24,7 @@ export default function SuggestedTools({ currentSlug, limit = 3 }: SuggestedTool
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relatedTools = currentTool.relatedSlugs
|
const relatedTools = getPopularToolSlugs(limit, [currentSlug, ...currentTool.relatedSlugs])
|
||||||
.map((slug) => getToolSEO(slug))
|
.map((slug) => getToolSEO(slug))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { CheckCircle } from 'lucide-react';
|
import { CheckCircle } from 'lucide-react';
|
||||||
import { getToolSEO } from '@/config/seoData';
|
import { getToolSEO } from '@/config/seoData';
|
||||||
import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, generateHowTo, getOgLocale, getSiteOrigin } from '@/utils/seo';
|
import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, generateHowTo, getOgLocale, getSiteOrigin } from '@/utils/seo';
|
||||||
|
import BreadcrumbNav from './BreadcrumbNav';
|
||||||
import FAQSection from './FAQSection';
|
import FAQSection from './FAQSection';
|
||||||
import RelatedTools from './RelatedTools';
|
import RelatedTools from './RelatedTools';
|
||||||
|
import SuggestedTools from './SuggestedTools';
|
||||||
import ToolRating from '@/components/shared/ToolRating';
|
import ToolRating from '@/components/shared/ToolRating';
|
||||||
import SharePanel from '@/components/shared/SharePanel';
|
import SharePanel from '@/components/shared/SharePanel';
|
||||||
import ToolWorkflowPanel from '@/components/shared/ToolWorkflowPanel';
|
import ToolWorkflowPanel from '@/components/shared/ToolWorkflowPanel';
|
||||||
@@ -76,6 +78,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
|||||||
<title>{toolTitle} — {seo.titleSuffix} | {t('common.appName')}</title>
|
<title>{toolTitle} — {seo.titleSuffix} | {t('common.appName')}</title>
|
||||||
<meta name="description" content={seo.metaDescription} />
|
<meta name="description" content={seo.metaDescription} />
|
||||||
<meta name="keywords" content={seo.keywords} />
|
<meta name="keywords" content={seo.keywords} />
|
||||||
|
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
||||||
<link rel="canonical" href={canonicalUrl} />
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
{languageAlternates.map((alternate) => (
|
{languageAlternates.map((alternate) => (
|
||||||
<link
|
<link
|
||||||
@@ -120,6 +123,14 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
|||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
{/* Tool Interface */}
|
{/* Tool Interface */}
|
||||||
|
<div className="mx-auto mb-6 max-w-5xl px-4">
|
||||||
|
<BreadcrumbNav
|
||||||
|
items={[
|
||||||
|
{ label: t('common.home'), to: '/' },
|
||||||
|
{ label: toolTitle },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<div className="mx-auto mt-6 flex max-w-3xl flex-wrap items-start justify-center gap-3 px-4">
|
<div className="mx-auto mt-6 flex max-w-3xl flex-wrap items-start justify-center gap-3 px-4">
|
||||||
@@ -222,6 +233,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
|||||||
|
|
||||||
{/* Related Tools */}
|
{/* Related Tools */}
|
||||||
<RelatedTools currentSlug={slug} />
|
<RelatedTools currentSlug={slug} />
|
||||||
|
<SuggestedTools currentSlug={slug} limit={4} />
|
||||||
|
|
||||||
{/* User Rating */}
|
{/* User Rating */}
|
||||||
<ToolRating toolSlug={slug} />
|
<ToolRating toolSlug={slug} />
|
||||||
|
|||||||
167
frontend/src/components/tools/ImageToSvg.tsx
Normal file
167
frontend/src/components/tools/ImageToSvg.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import { ImageIcon } from 'lucide-react';
|
||||||
|
import FileUploader from '@/components/shared/FileUploader';
|
||||||
|
import ProgressBar from '@/components/shared/ProgressBar';
|
||||||
|
import DownloadButton from '@/components/shared/DownloadButton';
|
||||||
|
import AdSlot from '@/components/layout/AdSlot';
|
||||||
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
|
type ColorMode = 'color' | 'binary';
|
||||||
|
|
||||||
|
export default function ImageToSvg() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||||
|
const [colorMode, setColorMode] = useState<ColorMode>('color');
|
||||||
|
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
uploadProgress,
|
||||||
|
isUploading,
|
||||||
|
taskId,
|
||||||
|
error: uploadError,
|
||||||
|
selectFile,
|
||||||
|
startUpload,
|
||||||
|
reset,
|
||||||
|
} = useFileUpload({
|
||||||
|
endpoint: '/image/to-svg',
|
||||||
|
maxSizeMB: 10,
|
||||||
|
acceptedTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||||
|
extraData: { color_mode: colorMode },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status, result, error: taskError } = useTaskPolling({
|
||||||
|
taskId,
|
||||||
|
onComplete: () => setPhase('done'),
|
||||||
|
onError: () => setPhase('done'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept file from homepage smart upload
|
||||||
|
const storeFile = useFileStore((s) => s.file);
|
||||||
|
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||||
|
useEffect(() => {
|
||||||
|
if (storeFile) {
|
||||||
|
selectFile(storeFile);
|
||||||
|
clearStoreFile();
|
||||||
|
}
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
const id = await startUpload();
|
||||||
|
if (id) setPhase('processing');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
reset();
|
||||||
|
setPhase('upload');
|
||||||
|
};
|
||||||
|
|
||||||
|
const modes: { value: ColorMode; label: string }[] = [
|
||||||
|
{ value: 'color', label: t('tools.imageToSvg.colorMode') },
|
||||||
|
{ value: 'binary', label: t('tools.imageToSvg.binaryMode') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const schema = generateToolSchema({
|
||||||
|
name: t('tools.imageToSvg.title'),
|
||||||
|
description: t('tools.imageToSvg.description'),
|
||||||
|
url: `${window.location.origin}/tools/image-to-svg`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{t('tools.imageToSvg.title')} — {t('common.appName')}</title>
|
||||||
|
<meta name="description" content={t('tools.imageToSvg.description')} />
|
||||||
|
<link rel="canonical" href={`${window.location.origin}/tools/image-to-svg`} />
|
||||||
|
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-100">
|
||||||
|
<ImageIcon className="h-8 w-8 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="section-heading">{t('tools.imageToSvg.title')}</h1>
|
||||||
|
<p className="mt-2 text-slate-500">{t('tools.imageToSvg.description')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||||
|
|
||||||
|
{phase === 'upload' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FileUploader
|
||||||
|
onFileSelect={selectFile}
|
||||||
|
file={file}
|
||||||
|
accept={{
|
||||||
|
'image/png': ['.png'],
|
||||||
|
'image/jpeg': ['.jpg', '.jpeg'],
|
||||||
|
'image/webp': ['.webp'],
|
||||||
|
}}
|
||||||
|
maxSizeMB={10}
|
||||||
|
isUploading={isUploading}
|
||||||
|
uploadProgress={uploadProgress}
|
||||||
|
error={uploadError}
|
||||||
|
onReset={handleReset}
|
||||||
|
acceptLabel="Images (PNG, JPG, WebP)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{file && !isUploading && (
|
||||||
|
<>
|
||||||
|
{/* Color Mode Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||||
|
{t('tools.imageToSvg.modeLabel')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{modes.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
onClick={() => setColorMode(m.value)}
|
||||||
|
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||||
|
colorMode === m.value
|
||||||
|
? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold'
|
||||||
|
: 'bg-white ring-slate-200 text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={handleUpload} className="btn-primary w-full">
|
||||||
|
{t('tools.imageToSvg.shortDesc')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'processing' && !result && (
|
||||||
|
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'done' && result && result.status === 'completed' && (
|
||||||
|
<DownloadButton result={result} onStartOver={handleReset} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'done' && taskError && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||||
|
<p className="text-sm text-red-700">{taskError}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleReset} className="btn-secondary w-full">
|
||||||
|
{t('common.startOver')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ export const TOOL_ROUTES = [
|
|||||||
'/tools/compress-image',
|
'/tools/compress-image',
|
||||||
'/tools/ocr',
|
'/tools/ocr',
|
||||||
'/tools/remove-background',
|
'/tools/remove-background',
|
||||||
|
'/tools/image-to-svg',
|
||||||
|
|
||||||
// Convert Tools
|
// Convert Tools
|
||||||
'/tools/html-to-pdf',
|
'/tools/html-to-pdf',
|
||||||
|
|||||||
@@ -382,7 +382,7 @@
|
|||||||
"en": "Conversion traffic is broad, so this page groups the workflows people use most when they need one format changed into another quickly from the browser.",
|
"en": "Conversion traffic is broad, so this page groups the workflows people use most when they need one format changed into another quickly from the browser.",
|
||||||
"ar": "ترافيك التحويل واسع، لذلك تجمع هذه الصفحة المسارات الأكثر استخداماً عندما يحتاج المستخدم إلى تغيير صيغة ملف إلى أخرى بسرعة من المتصفح."
|
"ar": "ترافيك التحويل واسع، لذلك تجمع هذه الصفحة المسارات الأكثر استخداماً عندما يحتاج المستخدم إلى تغيير صيغة ملف إلى أخرى بسرعة من المتصفح."
|
||||||
},
|
},
|
||||||
"targetToolSlugs": ["pdf-to-word", "word-to-pdf", "images-to-pdf", "image-converter", "html-to-pdf", "video-to-gif"],
|
"targetToolSlugs": ["pdf-to-word", "word-to-pdf", "images-to-pdf", "image-converter", "image-to-svg", "html-to-pdf", "video-to-gif"],
|
||||||
"faqTemplates": [
|
"faqTemplates": [
|
||||||
{
|
{
|
||||||
"question": {
|
"question": {
|
||||||
|
|||||||
@@ -448,6 +448,27 @@ export const TOOLS_SEO: ToolSEO[] = [
|
|||||||
{ question: 'What format is the output?', answer: 'The output is always a PNG file with a transparent background.' },
|
{ question: 'What format is the output?', answer: 'The output is always a PNG file with a transparent background.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
i18nKey: 'imageToSvg',
|
||||||
|
slug: 'image-to-svg',
|
||||||
|
titleSuffix: 'Free Online Image to SVG Converter',
|
||||||
|
metaDescription: 'Convert PNG, JPG, and WebP images to scalable SVG vector format online for free. Perfect for logos, icons, and graphics that need to scale without quality loss.',
|
||||||
|
category: 'Image',
|
||||||
|
relatedSlugs: ['image-converter', 'compress-image', 'image-resize', 'remove-background'],
|
||||||
|
keywords: 'image to svg, png to svg, jpg to svg, raster to vector, convert image to svg, vectorize image, image vectorizer',
|
||||||
|
features: [
|
||||||
|
'Convert raster images (PNG, JPG, WebP) to SVG',
|
||||||
|
'Color or black-and-white tracing modes',
|
||||||
|
'Scalable vector output — no pixelation',
|
||||||
|
'Perfect for logos, icons, and illustrations',
|
||||||
|
],
|
||||||
|
faqs: [
|
||||||
|
{ question: 'What image formats can I convert to SVG?', answer: 'You can convert PNG, JPG, JPEG, and WebP images to SVG vector format.' },
|
||||||
|
{ question: 'What is the difference between color and binary mode?', answer: 'Color mode preserves the full color palette. Binary mode converts the image to black and white first, producing cleaner vector paths — ideal for logos and line art.' },
|
||||||
|
{ question: 'Will the SVG be editable?', answer: 'Yes, the output SVG contains vector paths that can be edited in any vector editor such as Adobe Illustrator, Inkscape, or Figma.' },
|
||||||
|
{ question: 'Is there a file size limit?', answer: 'Images up to 10 MB are supported. For best results, use images under 4000×4000 pixels.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
i18nKey: 'ocr',
|
i18nKey: 'ocr',
|
||||||
slug: 'ocr',
|
slug: 'ocr',
|
||||||
@@ -881,6 +902,36 @@ export const TOOLS_SEO: ToolSEO[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const POPULAR_TOOL_SLUGS = [
|
||||||
|
'pdf-to-word',
|
||||||
|
'word-to-pdf',
|
||||||
|
'compress-pdf',
|
||||||
|
'merge-pdf',
|
||||||
|
'image-converter',
|
||||||
|
'image-resize',
|
||||||
|
'compress-image',
|
||||||
|
'ocr',
|
||||||
|
'html-to-pdf',
|
||||||
|
'pdf-to-excel',
|
||||||
|
'qr-code',
|
||||||
|
'video-to-gif',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function dedupeExistingToolSlugs(slugs: string[], excludeSlugs: string[] = []): string[] {
|
||||||
|
const excluded = new Set(excludeSlugs);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const validSlugs = new Set(TOOLS_SEO.map((tool) => tool.slug));
|
||||||
|
|
||||||
|
return slugs.filter((slug) => {
|
||||||
|
if (excluded.has(slug) || seen.has(slug) || !validSlugs.has(slug)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(slug);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Look up a tool's SEO data by slug */
|
/** Look up a tool's SEO data by slug */
|
||||||
export function getToolSEO(slug: string): ToolSEO | undefined {
|
export function getToolSEO(slug: string): ToolSEO | undefined {
|
||||||
return TOOLS_SEO.find((t) => t.slug === slug);
|
return TOOLS_SEO.find((t) => t.slug === slug);
|
||||||
@@ -890,3 +941,25 @@ export function getToolSEO(slug: string): ToolSEO | undefined {
|
|||||||
export function getAllToolSlugs(): string[] {
|
export function getAllToolSlugs(): string[] {
|
||||||
return TOOLS_SEO.map((t) => t.slug);
|
return TOOLS_SEO.map((t) => t.slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPopularToolSlugs(limit = 4, excludeSlugs: string[] = []): string[] {
|
||||||
|
return dedupeExistingToolSlugs([...POPULAR_TOOL_SLUGS], excludeSlugs).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInternalLinkToolSlugs(currentSlug: string, limit = 8): string[] {
|
||||||
|
const currentTool = getToolSEO(currentSlug);
|
||||||
|
if (!currentTool) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameCategorySlugs = TOOLS_SEO
|
||||||
|
.filter((tool) => tool.category === currentTool.category && tool.slug !== currentSlug)
|
||||||
|
.map((tool) => tool.slug);
|
||||||
|
|
||||||
|
const internalLinks = dedupeExistingToolSlugs(
|
||||||
|
[...currentTool.relatedSlugs, ...sameCategorySlugs, ...POPULAR_TOOL_SLUGS],
|
||||||
|
[currentSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
return internalLinks.slice(0, limit);
|
||||||
|
}
|
||||||
|
|||||||
@@ -443,6 +443,14 @@
|
|||||||
"lockAspect": "قفل نسبة العرض للارتفاع",
|
"lockAspect": "قفل نسبة العرض للارتفاع",
|
||||||
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع."
|
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع."
|
||||||
},
|
},
|
||||||
|
"imageToSvg": {
|
||||||
|
"title": "تحويل الصورة إلى SVG",
|
||||||
|
"description": "حوّل الصور النقطية (PNG, JPG, WebP) إلى صيغة SVG المتجهية القابلة للتحجيم. مثالية للشعارات والأيقونات والرسوم التوضيحية.",
|
||||||
|
"shortDesc": "تحويل إلى SVG",
|
||||||
|
"modeLabel": "وضع التتبع",
|
||||||
|
"colorMode": "ألوان كاملة",
|
||||||
|
"binaryMode": "أبيض وأسود"
|
||||||
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR — التعرف على النصوص",
|
"title": "OCR — التعرف على النصوص",
|
||||||
"description": "استخرج النصوص من الصور ومستندات PDF الممسوحة ضوئياً باستخدام التعرف الضوئي على الحروف.",
|
"description": "استخرج النصوص من الصور ومستندات PDF الممسوحة ضوئياً باستخدام التعرف الضوئي على الحروف.",
|
||||||
@@ -1234,6 +1242,18 @@
|
|||||||
{"q": "ما صيغة المخرجات؟", "a": "المخرجات دائماً ملف PNG بخلفية شفافة."}
|
{"q": "ما صيغة المخرجات؟", "a": "المخرجات دائماً ملف PNG بخلفية شفافة."}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"imageToSvg": {
|
||||||
|
"whatItDoes": "حوّل الصور النقطية (PNG, JPG, WebP) إلى صيغة SVG المتجهية القابلة للتحجيم. سواء كنت تحتاج شعارات أو أيقونات أو رسوم توضيحية متجهية، تتتبع هذه الأداة مسارات الصورة وتنتج ملف SVG نظيف يتوسع لأي حجم دون تشويش.",
|
||||||
|
"howToUse": ["ارفع ملف الصورة (PNG أو JPG أو WebP).", "اختر وضع التتبع: ألوان كاملة أو أبيض وأسود.", "انقر تحويل إلى SVG لبدء المعالجة.", "حمّل ملف SVG المتجهي القابل للتحجيم."],
|
||||||
|
"benefits": ["تحويل PNG و JPG و WebP إلى SVG", "وضع ألوان كاملة أو أبيض وأسود", "مخرجات متجهية قابلة للتحجيم — بدون تشويش", "قابل للتعديل في محررات الرسوم المتجهية (Illustrator, Figma, Inkscape)", "مجاني بدون تسجيل"],
|
||||||
|
"useCases": ["تحويل الشعارات إلى صيغة متجهية قابلة للتحجيم", "إنشاء أيقونات SVG للمواقع والتطبيقات", "تحويل الرسوم التوضيحية للمواد المطبوعة", "تحضير الرسومات للتصميم المتجاوب للويب", "تحويل الرسوم الخطية أو المخططات إلى مسارات قابلة للتعديل"],
|
||||||
|
"faq": [
|
||||||
|
{"q": "ما صيغ الصور التي يمكنني تحويلها إلى SVG؟", "a": "يمكنك تحويل صور PNG و JPG و JPEG و WebP إلى صيغة SVG المتجهية."},
|
||||||
|
{"q": "ما الفرق بين وضع الألوان ووضع الأبيض والأسود؟", "a": "وضع الألوان يحافظ على لوحة الألوان الكاملة لصورتك. وضع الأبيض والأسود يحوّل إلى أحادي اللون أولاً، منتجاً مسارات متجهية أنظف وأبسط — مثالي للشعارات والرسوم الخطية."},
|
||||||
|
{"q": "هل يمكنني تعديل ملف SVG الناتج؟", "a": "نعم، يحتوي ملف SVG الناتج على مسارات متجهية يمكن تعديلها في أي محرر رسومات متجهية."},
|
||||||
|
{"q": "هل يوجد حد لحجم الملف؟", "a": "يدعم صور حتى 10 ميجابايت. للحصول على أفضل النتائج، استخدم صور واضحة أقل من 4000×4000 بكسل."}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"whatItDoes": "استخرج النص من الصور ومستندات PDF الممسوحة ضوئياً باستخدام التعرف البصري على الحروف (OCR). يدعم محركنا المبني على Tesseract اللغات العربية والإنجليزية والفرنسية بدقة عالية.",
|
"whatItDoes": "استخرج النص من الصور ومستندات PDF الممسوحة ضوئياً باستخدام التعرف البصري على الحروف (OCR). يدعم محركنا المبني على Tesseract اللغات العربية والإنجليزية والفرنسية بدقة عالية.",
|
||||||
"howToUse": ["اختر نوع المصدر: صورة أو PDF.", "ارفع ملفك.", "اختر لغة OCR (الإنجليزية أو العربية أو الفرنسية).", "انقر استخراج النص وانسخ النتيجة."],
|
"howToUse": ["اختر نوع المصدر: صورة أو PDF.", "ارفع ملفك.", "اختر لغة OCR (الإنجليزية أو العربية أو الفرنسية).", "انقر استخراج النص وانسخ النتيجة."],
|
||||||
|
|||||||
@@ -443,6 +443,14 @@
|
|||||||
"lockAspect": "Lock aspect ratio",
|
"lockAspect": "Lock aspect ratio",
|
||||||
"aspectHint": "Enter one dimension — the other will auto-calculate to preserve aspect ratio."
|
"aspectHint": "Enter one dimension — the other will auto-calculate to preserve aspect ratio."
|
||||||
},
|
},
|
||||||
|
"imageToSvg": {
|
||||||
|
"title": "Image to SVG",
|
||||||
|
"description": "Convert raster images (PNG, JPG, WebP) to scalable SVG vector format. Perfect for logos, icons, and illustrations.",
|
||||||
|
"shortDesc": "Convert to SVG",
|
||||||
|
"modeLabel": "Tracing Mode",
|
||||||
|
"colorMode": "Full Color",
|
||||||
|
"binaryMode": "Black & White"
|
||||||
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR — Text Recognition",
|
"title": "OCR — Text Recognition",
|
||||||
"description": "Extract text from images and scanned PDF documents using optical character recognition.",
|
"description": "Extract text from images and scanned PDF documents using optical character recognition.",
|
||||||
@@ -1234,6 +1242,18 @@
|
|||||||
{"q": "What format is the output?", "a": "The output is always a PNG file with a transparent background."}
|
{"q": "What format is the output?", "a": "The output is always a PNG file with a transparent background."}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"imageToSvg": {
|
||||||
|
"whatItDoes": "Convert raster images (PNG, JPG, WebP) to scalable SVG vector format. Whether you need vector logos, icons, or illustrations, this tool traces the image paths and produces a clean SVG that scales to any size without pixelation.",
|
||||||
|
"howToUse": ["Upload your image file (PNG, JPG, or WebP).", "Choose the tracing mode: Full Color or Black & White.", "Click Convert to SVG to start processing.", "Download your scalable SVG vector file."],
|
||||||
|
"benefits": ["Convert PNG, JPG, and WebP to SVG", "Color or black-and-white tracing modes", "Scalable vector output — no pixelation", "Editable in vector editors (Illustrator, Figma, Inkscape)", "Free with no registration"],
|
||||||
|
"useCases": ["Converting logos to scalable vector format", "Creating SVG icons for websites and apps", "Vectorizing illustrations for print materials", "Preparing graphics for responsive web design", "Converting line art or diagrams to editable paths"],
|
||||||
|
"faq": [
|
||||||
|
{"q": "What image formats can I convert to SVG?", "a": "You can convert PNG, JPG, JPEG, and WebP images to SVG vector format."},
|
||||||
|
{"q": "What is the difference between Color and Black & White mode?", "a": "Color mode preserves the full color palette of your image. Black & White mode converts to monochrome first, producing cleaner and simpler vector paths — ideal for logos and line art."},
|
||||||
|
{"q": "Can I edit the resulting SVG?", "a": "Yes, the output SVG contains vector paths that can be edited in any vector graphics editor."},
|
||||||
|
{"q": "Is there a file size limit?", "a": "Images up to 10 MB are supported. For best results, use clear images under 4000×4000 pixels."}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"whatItDoes": "Extract text from images and scanned PDF documents using Optical Character Recognition (OCR). Our Tesseract-powered engine supports English, Arabic, and French text recognition with high accuracy.",
|
"whatItDoes": "Extract text from images and scanned PDF documents using Optical Character Recognition (OCR). Our Tesseract-powered engine supports English, Arabic, and French text recognition with high accuracy.",
|
||||||
"howToUse": ["Choose the source type: Image or PDF.", "Upload your file.", "Select the OCR language (English, Arabic, or French).", "Click Extract Text and copy the result."],
|
"howToUse": ["Choose the source type: Image or PDF.", "Upload your file.", "Select the OCR language (English, Arabic, or French).", "Click Extract Text and copy the result."],
|
||||||
|
|||||||
@@ -443,6 +443,14 @@
|
|||||||
"lockAspect": "Verrouiller le rapport d'aspect",
|
"lockAspect": "Verrouiller le rapport d'aspect",
|
||||||
"aspectHint": "Entrez une dimension — l'autre sera calculée automatiquement pour préserver le rapport d'aspect."
|
"aspectHint": "Entrez une dimension — l'autre sera calculée automatiquement pour préserver le rapport d'aspect."
|
||||||
},
|
},
|
||||||
|
"imageToSvg": {
|
||||||
|
"title": "Image vers SVG",
|
||||||
|
"description": "Convertissez des images raster (PNG, JPG, WebP) en format vectoriel SVG évolutif. Idéal pour les logos, icônes et illustrations.",
|
||||||
|
"shortDesc": "Convertir en SVG",
|
||||||
|
"modeLabel": "Mode de tracé",
|
||||||
|
"colorMode": "Couleurs complètes",
|
||||||
|
"binaryMode": "Noir et blanc"
|
||||||
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR — Reconnaissance de texte",
|
"title": "OCR — Reconnaissance de texte",
|
||||||
"description": "Extrayez le texte des images et des documents PDF numérisés grâce à la reconnaissance optique de caractères.",
|
"description": "Extrayez le texte des images et des documents PDF numérisés grâce à la reconnaissance optique de caractères.",
|
||||||
@@ -1234,6 +1242,18 @@
|
|||||||
{"q": "Quel est le format de sortie ?", "a": "La sortie est toujours un fichier PNG avec un arrière-plan transparent."}
|
{"q": "Quel est le format de sortie ?", "a": "La sortie est toujours un fichier PNG avec un arrière-plan transparent."}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"imageToSvg": {
|
||||||
|
"whatItDoes": "Convertissez des images raster (PNG, JPG, WebP) en format vectoriel SVG évolutif. Que vous ayez besoin de logos, d'icônes ou d'illustrations vectoriels, cet outil trace les chemins de l'image et produit un SVG propre qui s'adapte à toute taille sans pixelisation.",
|
||||||
|
"howToUse": ["Téléchargez votre fichier image (PNG, JPG ou WebP).", "Choisissez le mode de traçage : Couleurs complètes ou Noir et blanc.", "Cliquez sur Convertir en SVG pour démarrer le traitement.", "Téléchargez votre fichier vectoriel SVG évolutif."],
|
||||||
|
"benefits": ["Conversion de PNG, JPG et WebP en SVG", "Modes de traçage couleur ou noir et blanc", "Sortie vectorielle évolutive — sans pixelisation", "Modifiable dans les éditeurs vectoriels (Illustrator, Figma, Inkscape)", "Gratuit sans inscription"],
|
||||||
|
"useCases": ["Convertir des logos en format vectoriel évolutif", "Créer des icônes SVG pour les sites web et les applications", "Vectoriser des illustrations pour les supports imprimés", "Préparer des graphiques pour le design web responsive", "Convertir des dessins au trait ou des diagrammes en chemins modifiables"],
|
||||||
|
"faq": [
|
||||||
|
{"q": "Quels formats d'image puis-je convertir en SVG ?", "a": "Vous pouvez convertir des images PNG, JPG, JPEG et WebP en format vectoriel SVG."},
|
||||||
|
{"q": "Quelle est la différence entre le mode Couleur et Noir et blanc ?", "a": "Le mode couleur préserve la palette complète de votre image. Le mode noir et blanc convertit d'abord en monochrome, produisant des chemins vectoriels plus propres et plus simples — idéal pour les logos et les dessins au trait."},
|
||||||
|
{"q": "Puis-je modifier le SVG résultant ?", "a": "Oui, le SVG de sortie contient des chemins vectoriels modifiables dans n'importe quel éditeur graphique vectoriel."},
|
||||||
|
{"q": "Y a-t-il une limite de taille de fichier ?", "a": "Les images jusqu'à 10 Mo sont prises en charge. Pour de meilleurs résultats, utilisez des images claires de moins de 4000×4000 pixels."}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"whatItDoes": "Extrayez du texte à partir d'images et de documents PDF numérisés grâce à la reconnaissance optique de caractères (OCR). Notre moteur basé sur Tesseract prend en charge l'arabe, l'anglais et le français avec une haute précision.",
|
"whatItDoes": "Extrayez du texte à partir d'images et de documents PDF numérisés grâce à la reconnaissance optique de caractères (OCR). Notre moteur basé sur Tesseract prend en charge l'arabe, l'anglais et le français avec une haute précision.",
|
||||||
"howToUse": ["Sélectionnez le type de source : image ou PDF.", "Téléchargez votre fichier.", "Choisissez la langue OCR (anglais, arabe ou français).", "Cliquez sur Extraire le texte et copiez le résultat."],
|
"howToUse": ["Sélectionnez le type de source : image ou PDF.", "Téléchargez votre fichier.", "Choisissez la langue OCR (anglais, arabe ou français).", "Cliquez sur Extraire le texte et copiez le résultat."],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ArrowRight, FolderKanban, Link2 } from 'lucide-react';
|
import { ArrowRight, FolderKanban, Link2 } from 'lucide-react';
|
||||||
|
import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import FAQSection from '@/components/seo/FAQSection';
|
import FAQSection from '@/components/seo/FAQSection';
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +101,14 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
|||||||
|
|
||||||
<div className="mx-auto max-w-6xl space-y-10">
|
<div className="mx-auto max-w-6xl space-y-10">
|
||||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
|
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
|
||||||
|
<BreadcrumbNav
|
||||||
|
className="mb-6"
|
||||||
|
items={[
|
||||||
|
{ label: t('common.home'), to: '/' },
|
||||||
|
{ label: copy.breadcrumbLabel },
|
||||||
|
{ label: title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||||
<FolderKanban className="h-7 w-7" />
|
<FolderKanban className="h-7 w-7" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ArrowRight, CheckCircle, FileText, Link2 } from 'lucide-react';
|
import { ArrowRight, CheckCircle, FileText, Link2 } from 'lucide-react';
|
||||||
|
import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import FAQSection from '@/components/seo/FAQSection';
|
import FAQSection from '@/components/seo/FAQSection';
|
||||||
import RelatedTools from '@/components/seo/RelatedTools';
|
import RelatedTools from '@/components/seo/RelatedTools';
|
||||||
@@ -146,6 +147,14 @@ export default function SeoPage({ slug }: SeoPageProps) {
|
|||||||
|
|
||||||
<div className="mx-auto max-w-6xl space-y-12">
|
<div className="mx-auto max-w-6xl space-y-12">
|
||||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
|
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
|
||||||
|
<BreadcrumbNav
|
||||||
|
className="mb-6"
|
||||||
|
items={[
|
||||||
|
{ label: t('common.home'), to: '/' },
|
||||||
|
{ label: copy.breadcrumbLabel },
|
||||||
|
{ label: title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start">
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
<p className="mb-3 text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const imageTools: ToolOption[] = [
|
|||||||
{ key: 'removeBg', path: '/tools/remove-background', icon: ImageIcon, bgColor: 'bg-fuchsia-100 dark:bg-fuchsia-900/30', iconColor: 'text-fuchsia-600 dark:text-fuchsia-400' },
|
{ key: 'removeBg', path: '/tools/remove-background', icon: ImageIcon, bgColor: 'bg-fuchsia-100 dark:bg-fuchsia-900/30', iconColor: 'text-fuchsia-600 dark:text-fuchsia-400' },
|
||||||
{ key: 'imagesToPdf', path: '/tools/images-to-pdf', icon: FileImage, bgColor: 'bg-lime-100 dark:bg-lime-900/30', iconColor: 'text-lime-600 dark:text-lime-400' },
|
{ key: 'imagesToPdf', path: '/tools/images-to-pdf', icon: FileImage, bgColor: 'bg-lime-100 dark:bg-lime-900/30', iconColor: 'text-lime-600 dark:text-lime-400' },
|
||||||
{ key: 'compressImage', path: '/tools/compress-image', icon: Minimize2, bgColor: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
|
{ key: 'compressImage', path: '/tools/compress-image', icon: Minimize2, bgColor: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
|
||||||
|
{ key: 'imageToSvg', path: '/tools/image-to-svg', icon: ImageIcon, bgColor: 'bg-indigo-100 dark:bg-indigo-900/30', iconColor: 'text-indigo-600 dark:text-indigo-400' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Video tools available when a video is uploaded */
|
/** Video tools available when a video is uploaded */
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ server {
|
|||||||
# Frontend static files
|
# Frontend static files
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Link "<https://dociva.io$uri>; rel=canonical" always;
|
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
|
|||||||
Reference in New Issue
Block a user