تم الانتهاء من آخر دفعة تحسينات على المشروع، وتشمل:

تحويل لوحة الإدارة الداخلية من secret header إلى session auth حقيقي مع صلاحيات admin.
إضافة دعم إدارة الأدوار من داخل لوحة الإدارة نفسها، مع حماية الحسابات المعتمدة عبر INTERNAL_ADMIN_EMAILS.
تحسين بيانات المستخدم في الواجهة والباكند لتشمل role وis_allowlisted_admin.
إضافة اختبار frontend مخصص لصفحة /internal/admin بدل الاعتماد فقط على build واختبار routes.
تحسين إضافي في الأداء عبر إزالة الاعتماد على pdfjs-dist/pdf.worker في عدّ صفحات PDF واستبداله بمسار أخف باستخدام pdf-lib.
تحسين تقسيم الـ chunks في build لتقليل أثر الحزم الكبيرة وفصل أجزاء مثل network, icons, pdf-core, وeditor.
التحقق الذي تم:

نجاح build للواجهة.
نجاح اختبار صفحة الإدارة الداخلية في frontend.
نجاح اختبارات auth/admin في backend.
نجاح full backend suite مسبقًا مع EXIT:0.
ولو تريد نسخة أقصر جدًا، استخدم هذه:

آخر التحديثات:
تم تحسين نظام الإدارة الداخلية ليعتمد على صلاحيات وجلسات حقيقية بدل secret header، مع إضافة إدارة أدوار من لوحة admin نفسها، وإضافة اختبارات frontend مخصصة للوحة، وتحسين أداء الواجهة عبر إزالة pdf.worker وتحسين تقسيم الـ chunks في build. جميع الاختبارات والتحققات الأساسية المطلوبة نجح
This commit is contained in:
Your Name
2026-03-16 13:50:45 +02:00
parent b5d97324a9
commit 957d37838c
85 changed files with 9915 additions and 119 deletions

View File

@@ -111,6 +111,12 @@ export default function Footer() {
>
{t('common.blog')}
</Link>
<Link
to="/developers"
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
>
{t('common.developers')}
</Link>
</div>
</div>
</div>

View File

@@ -92,6 +92,12 @@ export default function Header() {
>
{t('common.account')}
</Link>
<Link
to="/developers"
className="text-sm font-medium text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
>
{t('common.developers')}
</Link>
</nav>
{/* Actions */}
@@ -189,6 +195,13 @@ export default function Header() {
>
{user?.email || t('common.account')}
</Link>
<Link
to="/developers"
onClick={() => setMobileOpen(false)}
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
>
{t('common.developers')}
</Link>
</nav>
)}
</header>

View File

@@ -1,4 +1,6 @@
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { buildLanguageAlternates, getOgLocale } from '@/utils/seo';
const SITE_NAME = 'SaaS-PDF';
@@ -23,9 +25,12 @@ interface SEOHeadProps {
* - Optional JSON-LD structured data
*/
export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) {
const { i18n } = useTranslation();
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const canonicalUrl = `${origin}${path}`;
const fullTitle = `${title}${SITE_NAME}`;
const languageAlternates = buildLanguageAlternates(origin, path);
const currentOgLocale = getOgLocale(i18n.language);
const schemas = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
@@ -34,6 +39,15 @@ export default function SEOHead({ title, description, path, type = 'website', js
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
{languageAlternates.map((alternate) => (
<link
key={alternate.hrefLang}
rel="alternate"
hrefLang={alternate.hrefLang}
href={alternate.href}
/>
))}
<link rel="alternate" hrefLang="x-default" href={canonicalUrl} />
{/* OpenGraph */}
<meta property="og:title" content={fullTitle} />
@@ -41,9 +55,12 @@ export default function SEOHead({ title, description, path, type = 'website', js
<meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content={type} />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:locale" content="en_US" />
<meta property="og:locale:alternate" content="ar_SA" />
<meta property="og:locale:alternate" content="fr_FR" />
<meta property="og:locale" content={currentOgLocale} />
{languageAlternates
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
.map((alternate) => (
<meta key={alternate.ogLocale} property="og:locale:alternate" content={alternate.ogLocale} />
))}
{/* Twitter */}
<meta name="twitter:card" content="summary" />

View File

@@ -0,0 +1,74 @@
import { ArrowRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getToolSEO } from '@/config/seoData';
interface SuggestedToolsProps {
currentSlug: string;
limit?: number;
}
const CATEGORY_COLORS: Record<string, string> = {
PDF: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400',
Image: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400',
AI: 'bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-400',
Convert: 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400',
Utility: 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400',
};
export default function SuggestedTools({ currentSlug, limit = 3 }: SuggestedToolsProps) {
const { t } = useTranslation();
const currentTool = getToolSEO(currentSlug);
if (!currentTool) {
return null;
}
const relatedTools = currentTool.relatedSlugs
.map((slug) => getToolSEO(slug))
.filter(Boolean)
.slice(0, limit);
if (relatedTools.length === 0) {
return null;
}
return (
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900/60">
<div className="mb-4">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
{t('home.suggestedTools')}
</h3>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
{t('home.suggestedToolsDesc')}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{relatedTools.map((tool) => (
<Link
key={tool!.slug}
to={`/tools/${tool!.slug}`}
className="group rounded-xl border border-slate-200 bg-slate-50 p-4 transition-colors hover:border-primary-300 hover:bg-white dark:border-slate-700 dark:bg-slate-800 dark:hover:border-primary-600"
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-semibold text-slate-800 group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400">
{t(`tools.${tool!.i18nKey}.title`)}
</h4>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${CATEGORY_COLORS[tool!.category] || ''}`}>
{tool!.category}
</span>
</div>
<p className="mt-2 text-xs leading-5 text-slate-600 dark:text-slate-400">
{t(`tools.${tool!.i18nKey}.shortDesc`)}
</p>
<span className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary-600 dark:text-primary-400">
{t('common.tryOtherTools')}
<ArrowRight className="h-3.5 w-3.5" />
</span>
</Link>
))}
</div>
</section>
);
}

View File

@@ -2,11 +2,12 @@ import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { CheckCircle } from 'lucide-react';
import { getToolSEO } from '@/config/seoData';
import { generateToolSchema, generateBreadcrumbs, generateFAQ } from '@/utils/seo';
import { buildLanguageAlternates, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo';
import FAQSection from './FAQSection';
import RelatedTools from './RelatedTools';
import ToolRating from '@/components/shared/ToolRating';
import SharePanel from '@/components/shared/SharePanel';
import ToolWorkflowPanel from '@/components/shared/ToolWorkflowPanel';
import { useToolRating } from '@/hooks/useToolRating';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
@@ -27,7 +28,7 @@ interface ToolLandingPageProps {
* feature bullets, and proper meta tags around any tool component.
*/
export default function ToolLandingPage({ slug, children }: ToolLandingPageProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const seo = getToolSEO(slug);
const ratingData = useToolRating(slug);
@@ -37,7 +38,10 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
const toolTitle = t(`tools.${seo.i18nKey}.title`);
const toolDesc = t(`tools.${seo.i18nKey}.description`);
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const canonicalUrl = `${origin}/tools/${slug}`;
const path = `/tools/${slug}`;
const canonicalUrl = `${origin}${path}`;
const languageAlternates = buildLanguageAlternates(origin, path);
const currentOgLocale = getOgLocale(i18n.language);
const toolSchema = generateToolSchema({
name: toolTitle,
@@ -63,12 +67,27 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
<meta name="description" content={seo.metaDescription} />
<meta name="keywords" content={seo.keywords} />
<link rel="canonical" href={canonicalUrl} />
{languageAlternates.map((alternate) => (
<link
key={alternate.hrefLang}
rel="alternate"
hrefLang={alternate.hrefLang}
href={alternate.href}
/>
))}
<link rel="alternate" hrefLang="x-default" href={canonicalUrl} />
{/* Open Graph */}
<meta property="og:title" content={`${toolTitle}${seo.titleSuffix}`} />
<meta property="og:description" content={seo.metaDescription} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content="website" />
<meta property="og:locale" content={currentOgLocale} />
{languageAlternates
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
.map((alternate) => (
<meta key={alternate.ogLocale} property="og:locale:alternate" content={alternate.ogLocale} />
))}
{/* Twitter */}
<meta name="twitter:card" content="summary" />
@@ -109,6 +128,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
{/* SEO Content Below Tool */}
<div className="mx-auto mt-16 max-w-3xl">
<ToolWorkflowPanel />
{/* What this tool does */}
<section className="mb-12">
<h2 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">

View File

@@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { Download, RotateCcw, Clock } from 'lucide-react';
import type { TaskResult } from '@/services/api';
import { formatFileSize } from '@/utils/textTools';
import { trackEvent } from '@/services/analytics';
import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt';
import SharePanel from '@/components/shared/SharePanel';
import SuggestedTools from '@/components/seo/SuggestedTools';
interface DownloadButtonProps {
/** Task result containing download URL */
@@ -15,6 +17,10 @@ interface DownloadButtonProps {
export default function DownloadButton({ result, onStartOver }: DownloadButtonProps) {
const { t } = useTranslation();
const location = useLocation();
const currentToolSlug = location.pathname.startsWith('/tools/')
? location.pathname.replace('/tools/', '')
: null;
const handleDownloadClick = () => {
trackEvent('download_clicked', { filename: result.filename || 'unknown' });
@@ -103,6 +109,8 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
<RotateCcw className="h-4 w-4" />
{t('common.startOver')}
</button>
{currentToolSlug && <SuggestedTools currentSlug={currentToolSlug} />}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Star } from 'lucide-react';
import { getToolSEO } from '@/config/seoData';
import { getPublicStats, type PublicStatsSummary } from '@/services/api';
interface SocialProofStripProps {
className?: string;
}
export default function SocialProofStrip({ className = '' }: SocialProofStripProps) {
const { t } = useTranslation();
const [stats, setStats] = useState<PublicStatsSummary | null>(null);
useEffect(() => {
let cancelled = false;
getPublicStats()
.then((data) => {
if (!cancelled) {
setStats(data);
}
})
.catch(() => {
if (!cancelled) {
setStats(null);
}
});
return () => {
cancelled = true;
};
}, []);
if (!stats) {
return null;
}
const topTools = stats.top_tools.slice(0, 3).map((tool) => {
const seo = getToolSEO(tool.tool);
return seo ? t(`tools.${seo.i18nKey}.title`) : tool.tool;
});
const cards = [
{ label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() },
{ label: t('socialProof.successRate'), value: `${stats.success_rate}%` },
{ label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() },
{ label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` },
];
return (
<section className={`rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}>
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
{t('socialProof.badge')}
</p>
<h2 className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
{t('socialProof.title')}
</h2>
<p className="mt-2 text-slate-600 dark:text-slate-400">
{t('socialProof.subtitle')}
</p>
{topTools.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{topTools.map((tool) => (
<span key={tool} className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{tool}
</span>
))}
</div>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:min-w-[420px]">
{cards.map((card) => (
<div key={card.label} className="rounded-2xl bg-slate-50 p-4 dark:bg-slate-800/70">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{card.label}</p>
<p className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">{card.value}</p>
</div>
))}
</div>
</div>
<div className="mt-5 flex flex-col gap-3 border-t border-slate-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-700">
<p className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<Star className="h-4 w-4 text-amber-500" />
{t('socialProof.basedOnRatings', { count: stats.rating_count })}
</p>
<Link to="/developers" className="text-sm font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
{t('socialProof.viewDevelopers')}
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,84 @@
import { FolderClock, KeyRound, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
const workflowCards = [
{
key: 'history',
icon: FolderClock,
titleKey: 'account.onboardingFirstTaskTitle',
descriptionKey: 'account.onboardingFirstTaskDesc',
href: '/account',
ctaKey: 'common.account',
},
{
key: 'limits',
icon: ShieldCheck,
titleKey: 'account.onboardingUpgradeTitle',
descriptionKey: 'account.onboardingUpgradeDesc',
href: '/pricing',
ctaKey: 'common.pricing',
},
{
key: 'api',
icon: KeyRound,
titleKey: 'account.onboardingApiTitle',
descriptionKey: 'account.onboardingApiDesc',
href: '/developers',
ctaKey: 'pages.developers.getApiKey',
},
] as const;
export default function ToolWorkflowPanel() {
const { t } = useTranslation();
return (
<section className="mb-12 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<div className="flex flex-col gap-3 border-b border-slate-200 pb-5 dark:border-slate-700 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-300">
{t('account.onboardingTitle')}
</p>
<h2 className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
{t('account.onboardingSubtitle')}
</h2>
</div>
<Link
to="/account"
className="inline-flex items-center justify-center rounded-full bg-primary-600 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
>
{t('common.account')}
</Link>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{workflowCards.map((card) => {
const Icon = card.icon;
return (
<article
key={card.key}
className="rounded-2xl border border-slate-200 bg-slate-50 p-5 dark:border-slate-700 dark:bg-slate-950/50"
>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary-100 text-primary-700 dark:bg-primary-500/15 dark:text-primary-200">
<Icon className="h-5 w-5" />
</div>
<h3 className="mt-4 text-lg font-semibold text-slate-900 dark:text-white">
{t(card.titleKey)}
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{t(card.descriptionKey)}
</p>
<Link
to={card.href}
className="mt-4 inline-flex items-center text-sm font-semibold text-primary-700 transition-colors hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{t(card.ctaKey)}
</Link>
</article>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,118 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Barcode } from 'lucide-react';
import ProgressBar from '@/components/shared/ProgressBar';
import AdSlot from '@/components/layout/AdSlot';
import { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import api, { type TaskResponse } from '@/services/api';
const BARCODE_TYPES = ['code128', 'code39', 'ean13', 'ean8', 'upca', 'isbn13', 'isbn10', 'issn', 'pzn'] as const;
export default function BarcodeGenerator() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'input' | 'processing' | 'done'>('input');
const [data, setData] = useState('');
const [barcodeType, setBarcodeType] = useState('code128');
const [format, setFormat] = useState('png');
const [taskId, setTaskId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
const handleGenerate = async () => {
if (!data.trim()) return;
setError(null); setPhase('processing');
try {
const res = await api.post<TaskResponse>('/barcode/generate', {
data: data.trim(), barcode_type: barcodeType, format,
});
setTaskId(res.data.task_id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate barcode.');
setPhase('done');
}
};
const handleReset = () => { setPhase('input'); setData(''); setBarcodeType('code128'); setFormat('png'); setTaskId(null); setError(null); };
const downloadUrl = result?.download_url || null;
const schema = generateToolSchema({ name: t('tools.barcode.title'), description: t('tools.barcode.description'), url: `${window.location.origin}/tools/barcode-generator` });
return (
<>
<Helmet>
<title>{t('tools.barcode.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.barcode.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/barcode-generator`} />
<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-amber-100 dark:bg-amber-900/30">
<Barcode className="h-8 w-8 text-amber-600 dark:text-amber-400" />
</div>
<h1 className="section-heading">{t('tools.barcode.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.barcode.description')}</p>
</div>
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{phase === 'input' && (
<div className="space-y-4">
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700 space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{t('tools.barcode.dataLabel')}</label>
<input type="text" value={data} onChange={(e) => setData(e.target.value)}
placeholder={t('tools.barcode.dataPlaceholder')}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200" />
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{t('tools.barcode.typeLabel')}</label>
<select value={barcodeType} onChange={(e) => setBarcodeType(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200">
{BARCODE_TYPES.map((bt) => <option key={bt} value={bt}>{bt.toUpperCase()}</option>)}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{t('tools.barcode.formatLabel')}</label>
<select value={format} onChange={(e) => setFormat(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200">
<option value="png">PNG</option>
<option value="svg">SVG</option>
</select>
</div>
</div>
<button onClick={handleGenerate} disabled={!data.trim()}
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed">
{t('tools.barcode.shortDesc')}
</button>
</div>
)}
{phase === 'processing' && !result && <ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />}
{phase === 'done' && downloadUrl && (
<div className="space-y-4 text-center">
<div className="rounded-2xl bg-white p-6 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<img src={downloadUrl} alt="Barcode" className="mx-auto max-w-full" />
</div>
<div className="flex gap-3">
<a href={downloadUrl} download className="btn-primary flex-1">{t('common.download')}</a>
<button onClick={handleReset} className="btn-secondary flex-1">{t('common.startOver')}</button>
</div>
</div>
)}
{phase === 'done' && (taskError || error) && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError || error}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Scissors } 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 { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import api, { type TaskResponse } from '@/services/api';
export default function CropPdf() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const [file, setFile] = useState<File | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [margins, setMargins] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
const storeFile = useFileStore((s) => s.file);
const clearStoreFile = useFileStore((s) => s.clearFile);
useEffect(() => { if (storeFile) { setFile(storeFile); clearStoreFile(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleUpload = async () => {
if (!file) return;
setError(null); setPhase('processing');
try {
const fd = new FormData();
fd.append('file', file);
fd.append('left', String(margins.left));
fd.append('right', String(margins.right));
fd.append('top', String(margins.top));
fd.append('bottom', String(margins.bottom));
const res = await api.post<TaskResponse>('/pdf-tools/crop', fd);
setTaskId(res.data.task_id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to crop PDF.');
setPhase('done');
}
};
const handleReset = () => { setPhase('upload'); setFile(null); setTaskId(null); setError(null); setMargins({ left: 0, right: 0, top: 0, bottom: 0 }); };
const schema = generateToolSchema({ name: t('tools.cropPdf.title'), description: t('tools.cropPdf.description'), url: `${window.location.origin}/tools/crop-pdf` });
return (
<>
<Helmet>
<title>{t('tools.cropPdf.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.cropPdf.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/crop-pdf`} />
<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-yellow-100 dark:bg-yellow-900/30">
<Scissors className="h-8 w-8 text-yellow-600 dark:text-yellow-400" />
</div>
<h1 className="section-heading">{t('tools.cropPdf.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.cropPdf.description')}</p>
</div>
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{phase === 'upload' && (
<div className="space-y-4">
<FileUploader onFileSelect={setFile} file={file} accept={{ 'application/pdf': ['.pdf'] }} maxSizeMB={20} acceptLabel="PDF (.pdf)" />
{file && (
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<p className="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">{t('tools.cropPdf.marginsLabel')}</p>
<div className="grid grid-cols-2 gap-3">
{(['top', 'bottom', 'left', 'right'] as const).map((side) => (
<div key={side}>
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">{t(`tools.cropPdf.${side}`)}</label>
<input type="number" min={0} value={margins[side]} onChange={(e) => setMargins((m) => ({ ...m, [side]: Math.max(0, Number(e.target.value)) }))}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200" />
</div>
))}
</div>
</div>
)}
{file && <button onClick={handleUpload} className="btn-primary w-full">{t('tools.cropPdf.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 || error) && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError || error}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Table2 } 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';
export default function ExcelToPdf() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const { file, uploadProgress, isUploading, taskId, error: uploadError, selectFile, startUpload, reset } =
useFileUpload({ endpoint: '/convert/excel-to-pdf', maxSizeMB: 15, acceptedTypes: ['xlsx', 'xls'] });
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
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 schema = generateToolSchema({
name: t('tools.excelToPdf.title'), description: t('tools.excelToPdf.description'),
url: `${window.location.origin}/tools/excel-to-pdf`,
});
return (
<>
<Helmet>
<title>{t('tools.excelToPdf.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.excelToPdf.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/excel-to-pdf`} />
<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-green-100 dark:bg-green-900/30">
<Table2 className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<h1 className="section-heading">{t('tools.excelToPdf.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.excelToPdf.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={{
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
}}
maxSizeMB={15} isUploading={isUploading} uploadProgress={uploadProgress}
error={uploadError} onReset={handleReset} acceptLabel="Excel (.xlsx, .xls)" />
{file && !isUploading && (
<button onClick={handleUpload} className="btn-primary w-full">{t('tools.excelToPdf.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 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { FileCheck } 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';
export default function FlattenPdf() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const { file, uploadProgress, isUploading, taskId, error: uploadError, selectFile, startUpload, reset } =
useFileUpload({ endpoint: '/pdf-tools/flatten', maxSizeMB: 20, acceptedTypes: ['pdf'] });
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
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 schema = generateToolSchema({ name: t('tools.flattenPdf.title'), description: t('tools.flattenPdf.description'), url: `${window.location.origin}/tools/flatten-pdf` });
return (
<>
<Helmet>
<title>{t('tools.flattenPdf.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.flattenPdf.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/flatten-pdf`} />
<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-teal-100 dark:bg-teal-900/30">
<FileCheck className="h-8 w-8 text-teal-600 dark:text-teal-400" />
</div>
<h1 className="section-heading">{t('tools.flattenPdf.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.flattenPdf.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={{ 'application/pdf': ['.pdf'] }} maxSizeMB={20} acceptLabel="PDF (.pdf)" />
{file && <button onClick={handleUpload} disabled={isUploading} className="btn-primary w-full">{t('tools.flattenPdf.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 || uploadError) && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError || uploadError}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,105 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Crop } 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 { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import api, { type TaskResponse } from '@/services/api';
export default function ImageCrop() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const [file, setFile] = useState<File | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [coords, setCoords] = useState({ left: 0, top: 0, right: 100, bottom: 100 });
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
const storeFile = useFileStore((s) => s.file);
const clearStoreFile = useFileStore((s) => s.clearFile);
useEffect(() => { if (storeFile) { setFile(storeFile); clearStoreFile(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleUpload = async () => {
if (!file) return;
setError(null); setPhase('processing');
try {
const fd = new FormData();
fd.append('file', file);
fd.append('left', String(coords.left));
fd.append('top', String(coords.top));
fd.append('right', String(coords.right));
fd.append('bottom', String(coords.bottom));
const res = await api.post<TaskResponse>('/image/crop', fd);
setTaskId(res.data.task_id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to crop image.');
setPhase('done');
}
};
const handleReset = () => { setPhase('upload'); setFile(null); setTaskId(null); setError(null); setCoords({ left: 0, top: 0, right: 100, bottom: 100 }); };
const schema = generateToolSchema({ name: t('tools.imageCrop.title'), description: t('tools.imageCrop.description'), url: `${window.location.origin}/tools/image-crop` });
return (
<>
<Helmet>
<title>{t('tools.imageCrop.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.imageCrop.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/image-crop`} />
<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-pink-100 dark:bg-pink-900/30">
<Crop className="h-8 w-8 text-pink-600 dark:text-pink-400" />
</div>
<h1 className="section-heading">{t('tools.imageCrop.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.imageCrop.description')}</p>
</div>
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{phase === 'upload' && (
<div className="space-y-4">
<FileUploader onFileSelect={setFile} file={file}
accept={{ 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/webp': ['.webp'] }}
maxSizeMB={10} acceptLabel="Image (.png, .jpg, .webp)" />
{file && (
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<p className="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">{t('tools.imageCrop.coordsLabel')}</p>
<div className="grid grid-cols-2 gap-3">
{(['left', 'top', 'right', 'bottom'] as const).map((side) => (
<div key={side}>
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">{t(`tools.imageCrop.${side}`)}</label>
<input type="number" min={0} value={coords[side]} onChange={(e) => setCoords((c) => ({ ...c, [side]: Math.max(0, Number(e.target.value)) }))}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200" />
</div>
))}
</div>
</div>
)}
{file && <button onClick={handleUpload} className="btn-primary w-full">{t('tools.imageCrop.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 || error) && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError || error}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,116 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { RotateCw } 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 { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import api, { type TaskResponse } from '@/services/api';
export default function ImageRotateFlip() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const [file, setFile] = useState<File | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [rotation, setRotation] = useState(0);
const [flipH, setFlipH] = useState(false);
const [flipV, setFlipV] = useState(false);
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
const storeFile = useFileStore((s) => s.file);
const clearStoreFile = useFileStore((s) => s.clearFile);
useEffect(() => { if (storeFile) { setFile(storeFile); clearStoreFile(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleUpload = async () => {
if (!file) return;
setError(null); setPhase('processing');
try {
const fd = new FormData();
fd.append('file', file);
fd.append('rotation', String(rotation));
fd.append('flip_horizontal', String(flipH));
fd.append('flip_vertical', String(flipV));
const res = await api.post<TaskResponse>('/image/rotate-flip', fd);
setTaskId(res.data.task_id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to transform image.');
setPhase('done');
}
};
const handleReset = () => { setPhase('upload'); setFile(null); setTaskId(null); setError(null); setRotation(0); setFlipH(false); setFlipV(false); };
const schema = generateToolSchema({ name: t('tools.imageRotateFlip.title'), description: t('tools.imageRotateFlip.description'), url: `${window.location.origin}/tools/image-rotate-flip` });
return (
<>
<Helmet>
<title>{t('tools.imageRotateFlip.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.imageRotateFlip.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/image-rotate-flip`} />
<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-cyan-100 dark:bg-cyan-900/30">
<RotateCw className="h-8 w-8 text-cyan-600 dark:text-cyan-400" />
</div>
<h1 className="section-heading">{t('tools.imageRotateFlip.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.imageRotateFlip.description')}</p>
</div>
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{phase === 'upload' && (
<div className="space-y-4">
<FileUploader onFileSelect={setFile} file={file}
accept={{ 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/webp': ['.webp'] }}
maxSizeMB={10} acceptLabel="Image (.png, .jpg, .webp)" />
{file && (
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700 space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{t('tools.imageRotateFlip.rotationLabel')}</label>
<select value={rotation} onChange={(e) => setRotation(Number(e.target.value))}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200">
<option value={0}>0°</option>
<option value={90}>90°</option>
<option value={180}>180°</option>
<option value={270}>270°</option>
</select>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={flipH} onChange={(e) => setFlipH(e.target.checked)} className="rounded" />
{t('tools.imageRotateFlip.flipHorizontal')}
</label>
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={flipV} onChange={(e) => setFlipV(e.target.checked)} className="rounded" />
{t('tools.imageRotateFlip.flipVertical')}
</label>
</div>
</div>
)}
{file && <button onClick={handleUpload} className="btn-primary w-full">{t('tools.imageRotateFlip.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 || error) && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError || error}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { FileText } 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 { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import api, { type TaskResponse } from '@/services/api';
export default function PdfMetadata() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const [file, setFile] = useState<File | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [meta, setMeta] = useState({ title: '', author: '', subject: '', keywords: '', creator: '' });
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
const storeFile = useFileStore((s) => s.file);
const clearStoreFile = useFileStore((s) => s.clearFile);
useEffect(() => { if (storeFile) { setFile(storeFile); clearStoreFile(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleUpload = async () => {
if (!file) return;
setError(null); setPhase('processing');
try {
const fd = new FormData();
fd.append('file', file);
Object.entries(meta).forEach(([k, v]) => { if (v.trim()) fd.append(k, v.trim()); });
const res = await api.post<TaskResponse>('/pdf-tools/metadata', fd);
setTaskId(res.data.task_id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to edit metadata.');
setPhase('done');
}
};
const handleReset = () => { setPhase('upload'); setFile(null); setTaskId(null); setError(null); setMeta({ title: '', author: '', subject: '', keywords: '', creator: '' }); };
const schema = generateToolSchema({ name: t('tools.pdfMetadata.title'), description: t('tools.pdfMetadata.description'), url: `${window.location.origin}/tools/pdf-metadata` });
const fields = ['title', 'author', 'subject', 'keywords', 'creator'] as const;
const fieldLabelKeys: Record<string, string> = { title: 'titleField', author: 'author', subject: 'subject', keywords: 'keywords', creator: 'creator' };
return (
<>
<Helmet>
<title>{t('tools.pdfMetadata.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.pdfMetadata.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/pdf-metadata`} />
<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 dark:bg-indigo-900/30">
<FileText className="h-8 w-8 text-indigo-600 dark:text-indigo-400" />
</div>
<h1 className="section-heading">{t('tools.pdfMetadata.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.pdfMetadata.description')}</p>
</div>
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{phase === 'upload' && (
<div className="space-y-4">
<FileUploader onFileSelect={setFile} file={file} accept={{ 'application/pdf': ['.pdf'] }} maxSizeMB={20} acceptLabel="PDF (.pdf)" />
{file && (
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700 space-y-3">
{fields.map((f) => (
<div key={f}>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">{t(`tools.pdfMetadata.${fieldLabelKeys[f]}`)}</label>
<input type="text" value={meta[f]} onChange={(e) => setMeta((m) => ({ ...m, [f]: e.target.value }))}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
placeholder={t(`tools.pdfMetadata.${f}Placeholder`)} />
</div>
))}
</div>
)}
{file && <button onClick={handleUpload} className="btn-primary w-full">{t('tools.pdfMetadata.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 || error) && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError || error}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { FileText } 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';
export default function PdfToPptx() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const { file, uploadProgress, isUploading, taskId, error: uploadError, selectFile, startUpload, reset } =
useFileUpload({ endpoint: '/convert/pdf-to-pptx', maxSizeMB: 20, acceptedTypes: ['pdf'] });
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
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 schema = generateToolSchema({
name: t('tools.pdfToPptx.title'), description: t('tools.pdfToPptx.description'),
url: `${window.location.origin}/tools/pdf-to-pptx`,
});
return (
<>
<Helmet>
<title>{t('tools.pdfToPptx.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.pdfToPptx.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/pdf-to-pptx`} />
<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-orange-100 dark:bg-orange-900/30">
<FileText className="h-8 w-8 text-orange-600 dark:text-orange-400" />
</div>
<h1 className="section-heading">{t('tools.pdfToPptx.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.pdfToPptx.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={{ 'application/pdf': ['.pdf'] }}
maxSizeMB={20} isUploading={isUploading} uploadProgress={uploadProgress}
error={uploadError} onReset={handleReset} acceptLabel="PDF (.pdf)" />
{file && !isUploading && (
<button onClick={handleUpload} className="btn-primary w-full">{t('tools.pdfToPptx.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 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Presentation } 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';
export default function PptxToPdf() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const { file, uploadProgress, isUploading, taskId, error: uploadError, selectFile, startUpload, reset } =
useFileUpload({ endpoint: '/convert/pptx-to-pdf', maxSizeMB: 20, acceptedTypes: ['pptx', 'ppt'] });
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
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 schema = generateToolSchema({
name: t('tools.pptxToPdf.title'), description: t('tools.pptxToPdf.description'),
url: `${window.location.origin}/tools/pptx-to-pdf`,
});
return (
<>
<Helmet>
<title>{t('tools.pptxToPdf.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.pptxToPdf.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/pptx-to-pdf`} />
<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-orange-100 dark:bg-orange-900/30">
<Presentation className="h-8 w-8 text-orange-600 dark:text-orange-400" />
</div>
<h1 className="section-heading">{t('tools.pptxToPdf.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.pptxToPdf.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={{
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
'application/vnd.ms-powerpoint': ['.ppt'],
}}
maxSizeMB={20} isUploading={isUploading} uploadProgress={uploadProgress}
error={uploadError} onReset={handleReset} acceptLabel="PowerPoint (.pptx, .ppt)" />
{file && !isUploading && (
<button onClick={handleUpload} className="btn-primary w-full">{t('tools.pptxToPdf.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 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -2,8 +2,6 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { ArrowUpDown } from 'lucide-react';
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import FileUploader from '@/components/shared/FileUploader';
import ProgressBar from '@/components/shared/ProgressBar';
import DownloadButton from '@/components/shared/DownloadButton';
@@ -11,10 +9,9 @@ import AdSlot from '@/components/layout/AdSlot';
import { useFileUpload } from '@/hooks/useFileUpload';
import { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { getPdfPageCount } from '@/utils/pdfClient';
import { useFileStore } from '@/stores/fileStore';
GlobalWorkerOptions.workerSrc = pdfWorker;
export default function ReorderPdf() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
@@ -47,7 +44,6 @@ export default function ReorderPdf() {
useEffect(() => {
let cancelled = false;
let loadingTask: ReturnType<typeof getDocument> | null = null;
async function detectPageCount(selectedFile: File) {
setIsReadingPageCount(true);
@@ -55,12 +51,9 @@ export default function ReorderPdf() {
setPageCountError(null);
try {
const data = new Uint8Array(await selectedFile.arrayBuffer());
loadingTask = getDocument({ data });
const pdf = await loadingTask.promise;
const count = await getPdfPageCount(selectedFile);
if (!cancelled) {
setPageCount(pdf.numPages);
setPageCount(count);
}
} catch {
if (!cancelled) {
@@ -70,7 +63,6 @@ export default function ReorderPdf() {
if (!cancelled) {
setIsReadingPageCount(false);
}
void loadingTask?.destroy();
}
}
@@ -85,7 +77,6 @@ export default function ReorderPdf() {
return () => {
cancelled = true;
void loadingTask?.destroy();
};
}, [file, t]);

View File

@@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Wrench } 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';
export default function RepairPdf() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const { file, uploadProgress, isUploading, taskId, error: uploadError, selectFile, startUpload, reset } =
useFileUpload({ endpoint: '/pdf-tools/repair', maxSizeMB: 20, acceptedTypes: ['pdf'] });
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
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 schema = generateToolSchema({ name: t('tools.repairPdf.title'), description: t('tools.repairPdf.description'), url: `${window.location.origin}/tools/repair-pdf` });
return (
<>
<Helmet>
<title>{t('tools.repairPdf.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.repairPdf.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/repair-pdf`} />
<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-red-100 dark:bg-red-900/30">
<Wrench className="h-8 w-8 text-red-600 dark:text-red-400" />
</div>
<h1 className="section-heading">{t('tools.repairPdf.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.repairPdf.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={{ 'application/pdf': ['.pdf'] }} maxSizeMB={20} acceptLabel="PDF (.pdf)" />
{file && <button onClick={handleUpload} disabled={isUploading} className="btn-primary w-full">{t('tools.repairPdf.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 || uploadError) && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError || uploadError}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -0,0 +1,121 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { PenTool } 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 { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import api, { type TaskResponse } from '@/services/api';
export default function SignPdf() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [sigFile, setSigFile] = useState<File | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const { status, result, error: taskError } = useTaskPolling({
taskId, onComplete: () => setPhase('done'), onError: () => setPhase('done'),
});
const storeFile = useFileStore((s) => s.file);
const clearStoreFile = useFileStore((s) => s.clearFile);
useEffect(() => { if (storeFile) { setPdfFile(storeFile); clearStoreFile(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleUpload = async () => {
if (!pdfFile || !sigFile) return;
setError(null);
setPhase('processing');
try {
const fd = new FormData();
fd.append('file', pdfFile);
fd.append('signature', sigFile);
fd.append('page', String(page));
const res = await api.post<TaskResponse>('/pdf-tools/sign', fd);
setTaskId(res.data.task_id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to sign PDF.');
setPhase('done');
}
};
const handleReset = () => {
setPhase('upload'); setPdfFile(null); setSigFile(null);
setTaskId(null); setError(null); setPage(1);
};
const schema = generateToolSchema({
name: t('tools.signPdf.title'), description: t('tools.signPdf.description'),
url: `${window.location.origin}/tools/sign-pdf`,
});
return (
<>
<Helmet>
<title>{t('tools.signPdf.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.signPdf.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/sign-pdf`} />
<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-blue-100 dark:bg-blue-900/30">
<PenTool className="h-8 w-8 text-blue-600 dark:text-blue-400" />
</div>
<h1 className="section-heading">{t('tools.signPdf.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.signPdf.description')}</p>
</div>
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{phase === 'upload' && (
<div className="space-y-4">
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700 space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t('tools.signPdf.pdfLabel')}
</label>
<FileUploader onFileSelect={setPdfFile} file={pdfFile}
accept={{ 'application/pdf': ['.pdf'] }} maxSizeMB={20}
acceptLabel="PDF (.pdf)" />
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t('tools.signPdf.signatureLabel')}
</label>
<FileUploader onFileSelect={setSigFile} file={sigFile}
accept={{ 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'] }} maxSizeMB={5}
acceptLabel="Image (.png, .jpg)" />
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t('tools.signPdf.pageLabel')}
</label>
<input type="number" min={1} value={page} onChange={(e) => setPage(Math.max(1, Number(e.target.value)))}
className="w-24 rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200" />
</div>
</div>
{pdfFile && sigFile && (
<button onClick={handleUpload} className="btn-primary w-full">{t('tools.signPdf.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 || error) && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{taskError || error}</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">{t('common.startOver')}</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}