Files
SaaS-PDF/frontend/src/components/tools/PdfEditor.tsx
Your Name cfbcc8bd79 ميزة: إضافة مكوني ProcedureSelection و StepProgress لأداة مخططات التدفق بصيغة PDF
- تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة.

- إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي.

- تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts.

- إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء.

- تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية.
2026-03-06 17:16:09 +02:00

246 lines
9.3 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import {
PenLine,
Save,
Download,
Undo2,
Redo2,
PlusCircle,
Trash2,
RotateCw,
FileOutput,
PanelLeft,
Share2,
ShieldCheck,
Info,
} 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 PdfEditor() {
const { t } = useTranslation();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const {
file,
uploadProgress,
isUploading,
taskId,
error: uploadError,
selectFile,
startUpload,
reset,
} = useFileUpload({
endpoint: '/compress/pdf',
maxSizeMB: 200,
acceptedTypes: ['pdf'],
extraData: { quality: 'high' },
});
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 schema = generateToolSchema({
name: t('tools.pdfEditor.title'),
description: t('tools.pdfEditor.description'),
url: `${window.location.origin}/tools/pdf-editor`,
});
const toolbarButtons = [
{ icon: Undo2, label: t('tools.pdfEditor.undo'), shortcut: 'Ctrl+Z' },
{ icon: Redo2, label: t('tools.pdfEditor.redo'), shortcut: 'Ctrl+Y' },
{ icon: PlusCircle, label: t('tools.pdfEditor.addPage') },
{ icon: Trash2, label: t('tools.pdfEditor.deletePage') },
{ icon: RotateCw, label: t('tools.pdfEditor.rotate') },
{ icon: FileOutput, label: t('tools.pdfEditor.extractPage') },
{ icon: PanelLeft, label: t('tools.pdfEditor.thumbnails') },
];
return (
<>
<Helmet>
<title>{t('tools.pdfEditor.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.pdfEditor.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/pdf-editor`} />
<script type="application/ld+json">{JSON.stringify(schema)}</script>
</Helmet>
<div className="mx-auto max-w-2xl">
{/* Header */}
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-rose-100 dark:bg-rose-900/30">
<PenLine className="h-8 w-8 text-rose-600 dark:text-rose-400" />
</div>
<h1 className="section-heading">{t('tools.pdfEditor.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">
{t('tools.pdfEditor.intro')}
</p>
</div>
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{/* Upload Phase */}
{phase === 'upload' && (
<div className="space-y-6">
<FileUploader
onFileSelect={selectFile}
file={file}
accept={{ 'application/pdf': ['.pdf'] }}
maxSizeMB={200}
isUploading={isUploading}
uploadProgress={uploadProgress}
error={uploadError}
onReset={handleReset}
acceptLabel="PDF"
/>
{file && !isUploading && (
<>
{/* Steps */}
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<ol className="space-y-3 text-sm text-slate-600 dark:text-slate-300">
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">1</span>
{t('tools.pdfEditor.steps.step1')}
</li>
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">2</span>
{t('tools.pdfEditor.steps.step2')}
</li>
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">3</span>
{t('tools.pdfEditor.steps.step3')}
</li>
</ol>
</div>
{/* Toolbar Preview */}
<div className="rounded-2xl bg-white p-4 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<p className="mb-3 text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
{t('tools.pdfEditor.thumbnails')}
</p>
<div className="flex flex-wrap gap-2">
{toolbarButtons.map((btn) => {
const Icon = btn.icon;
return (
<div
key={btn.label}
className="flex items-center gap-1.5 rounded-lg bg-slate-50 px-3 py-2 text-xs font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-600"
title={btn.shortcut ? `${btn.label} (${btn.shortcut})` : btn.label}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{btn.label}</span>
</div>
);
})}
</div>
</div>
{/* Upload Button */}
<button
onClick={handleUpload}
className="btn-primary w-full"
title={t('tools.pdfEditor.saveTooltip')}
>
<Save className="h-5 w-5" />
{t('tools.pdfEditor.save')}
</button>
{/* Version & Privacy Notes */}
<div className="space-y-3">
<div className="flex gap-2.5 rounded-xl bg-blue-50 p-3 text-xs text-blue-700 ring-1 ring-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:ring-blue-800">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>{t('tools.pdfEditor.versionNote')}</span>
</div>
<div className="flex gap-2.5 rounded-xl bg-emerald-50 p-3 text-xs text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:ring-emerald-800">
<ShieldCheck className="h-4 w-4 shrink-0 mt-0.5" />
<span>{t('tools.pdfEditor.privacyNote')}</span>
</div>
</div>
</>
)}
</div>
)}
{/* Processing Phase */}
{phase === 'processing' && !result && (
<div className="space-y-3">
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
{t('tools.pdfEditor.applyingChangesSub')}
</p>
</div>
)}
{/* Done Phase - Success */}
{phase === 'done' && result && result.status === 'completed' && (
<div className="space-y-4">
<DownloadButton result={result} onStartOver={handleReset} />
{/* Share button */}
{result.download_url && (
<button
onClick={() => {
navigator.clipboard.writeText(result.download_url!);
}}
className="btn-secondary w-full"
title={t('tools.pdfEditor.share')}
>
<Share2 className="h-5 w-5" />
{t('tools.pdfEditor.share')}
</button>
)}
</div>
)}
{/* Done Phase - Error */}
{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">
{t('tools.pdfEditor.processingFailed')}
</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">
{t('tools.pdfEditor.retry')}
</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}