ميزة: إضافة مكوني ProcedureSelection و StepProgress لأداة مخططات التدفق بصيغة PDF
- تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة. - إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي. - تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts. - إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء. - تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية.
This commit is contained in:
4045
frontend/package-lock.json
generated
4045
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,17 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"fabric": "^6.4.3",
|
||||
"i18next": "^23.11.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.4.168",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-dropzone": "^14.2.0",
|
||||
@@ -25,14 +29,19 @@
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const WatermarkPdf = lazy(() => import('@/components/tools/WatermarkPdf'));
|
||||
const ProtectPdf = lazy(() => import('@/components/tools/ProtectPdf'));
|
||||
const UnlockPdf = lazy(() => import('@/components/tools/UnlockPdf'));
|
||||
const AddPageNumbers = lazy(() => import('@/components/tools/AddPageNumbers'));
|
||||
const PdfEditor = lazy(() => import('@/components/tools/PdfEditor'));
|
||||
const PdfFlowchart = lazy(() => import('@/components/tools/PdfFlowchart'));
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
@@ -66,6 +68,8 @@ export default function App() {
|
||||
<Route path="/tools/protect-pdf" element={<ProtectPdf />} />
|
||||
<Route path="/tools/unlock-pdf" element={<UnlockPdf />} />
|
||||
<Route path="/tools/page-numbers" element={<AddPageNumbers />} />
|
||||
<Route path="/tools/pdf-editor" element={<PdfEditor />} />
|
||||
<Route path="/tools/pdf-flowchart" element={<PdfFlowchart />} />
|
||||
|
||||
{/* Image Tools */}
|
||||
<Route path="/tools/image-converter" element={<ImageConverter />} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, Sparkles } from 'lucide-react';
|
||||
import { Upload, Sparkles, PenLine } from 'lucide-react';
|
||||
import ToolSelectorModal from '@/components/shared/ToolSelectorModal';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
|
||||
import type { ToolOption } from '@/utils/fileRouting';
|
||||
|
||||
@@ -23,6 +25,8 @@ const ACCEPTED_TYPES = {
|
||||
|
||||
export default function HeroUploadZone() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const setStoreFile = useFileStore((s) => s.setFile);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [matchedTools, setMatchedTools] = useState<ToolOption[]>([]);
|
||||
const [fileTypeLabel, setFileTypeLabel] = useState('');
|
||||
@@ -102,11 +106,50 @@ export default function HeroUploadZone() {
|
||||
</div>
|
||||
|
||||
{/* CTA Text */}
|
||||
<p className="mb-1 text-lg font-semibold text-slate-800 dark:text-slate-200">
|
||||
{t('home.uploadCta')}
|
||||
</p>
|
||||
<div className="mb-6 flex gap-3 justify-center z-10 relative">
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-bold rounded-xl shadow-md transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = Object.values(ACCEPTED_TYPES).flat().join(',');
|
||||
input.onchange = (ev) => {
|
||||
const fileInput = ev.target as HTMLInputElement;
|
||||
const f = fileInput.files?.[0];
|
||||
if (f) onDrop([f]);
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
{t('home.uploadCta', 'Choose File')}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf';
|
||||
input.onchange = (ev) => {
|
||||
const fileInput = ev.target as HTMLInputElement;
|
||||
const f = fileInput.files?.[0];
|
||||
if (f) {
|
||||
setStoreFile(f);
|
||||
navigate('/tools/pdf-editor');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
className="px-6 py-3 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-xl shadow-md transition-colors flex items-center gap-2"
|
||||
>
|
||||
<PenLine className="h-5 w-5" />
|
||||
{t('home.editNow')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('home.uploadOr')}
|
||||
{t('common.dragDrop', 'or drop files here')}
|
||||
</p>
|
||||
|
||||
{/* Supported formats */}
|
||||
|
||||
@@ -22,21 +22,21 @@ export default function ToolCard({
|
||||
bgColor,
|
||||
}: ToolCardProps) {
|
||||
return (
|
||||
<Link to={to} className="tool-card group block">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${bgColor}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-slate-900 group-hover:text-primary-600 transition-colors dark:text-slate-100 dark:group-hover:text-primary-400">
|
||||
<Link to={to} className="group block h-full">
|
||||
<div className="flex h-full flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 transition-all duration-200 hover:-translate-y-1 hover:shadow-md hover:ring-primary-300 dark:bg-slate-800 dark:ring-slate-700 dark:hover:ring-primary-500">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${bgColor} dark:bg-slate-700 dark:group-hover:bg-slate-600`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-slate-900 transition-colors group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500 line-clamp-2 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 line-clamp-2 dark:text-slate-400 mt-1">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
245
frontend/src/components/tools/PdfEditor.tsx
Normal file
245
frontend/src/components/tools/PdfEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
360
frontend/src/components/tools/PdfFlowchart.tsx
Normal file
360
frontend/src/components/tools/PdfFlowchart.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
import type { Procedure, Flowchart, PDFPage, WizardStep } from './pdf-flowchart/types';
|
||||
import StepProgress from './pdf-flowchart/StepProgress';
|
||||
import FlowUpload from './pdf-flowchart/FlowUpload';
|
||||
import ProcedureSelection from './pdf-flowchart/ProcedureSelection';
|
||||
import DocumentViewer from './pdf-flowchart/DocumentViewer';
|
||||
import ManualProcedure from './pdf-flowchart/ManualProcedure';
|
||||
import FlowGeneration from './pdf-flowchart/FlowGeneration';
|
||||
import FlowChart from './pdf-flowchart/FlowChart';
|
||||
import FlowChat from './pdf-flowchart/FlowChat';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function PdfFlowchart() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Wizard state
|
||||
const [step, setStep] = useState<WizardStep>(0);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Data
|
||||
const [pages, setPages] = useState<PDFPage[]>([]);
|
||||
const [procedures, setProcedures] = useState<Procedure[]>([]);
|
||||
const [rejectedProcedures, setRejectedProcedures] = useState<Procedure[]>([]);
|
||||
const [flowcharts, setFlowcharts] = useState<Flowchart[]>([]);
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
|
||||
// Sub-views
|
||||
const [viewingProcedure, setViewingProcedure] = useState<Procedure | null>(null);
|
||||
const [addingManual, setAddingManual] = useState(false);
|
||||
const [viewingFlow, setViewingFlow] = useState<Flowchart | null>(null);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
|
||||
// Accept file from homepage smart-upload
|
||||
const storeFile = useFileStore((s) => s.file);
|
||||
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||
useEffect(() => {
|
||||
if (storeFile && storeFile.type === 'application/pdf') {
|
||||
setFile(storeFile);
|
||||
clearStoreFile();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Task polling for extraction
|
||||
const { error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: (res) => {
|
||||
if (res?.procedures) {
|
||||
setProcedures(res.procedures);
|
||||
setFlowcharts((res.flowcharts || []) as unknown as Flowchart[]);
|
||||
if (res.pages) setPages(res.pages as unknown as PDFPage[]);
|
||||
setStep(1);
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setError(taskError || t('common.error'));
|
||||
setStep(0);
|
||||
setUploading(false);
|
||||
},
|
||||
});
|
||||
|
||||
// ------ Handlers ------
|
||||
const handleFileSelect = (f: File) => {
|
||||
if (f.type === 'application/pdf') {
|
||||
setFile(f);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/flowchart/extract', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error || 'Upload failed.');
|
||||
setTaskId(data.task_id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed.');
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrySample = async () => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/flowchart/extract-sample', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Sample failed.');
|
||||
setTaskId(data.task_id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sample failed.');
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectProcedure = (id: string) => {
|
||||
const proc = procedures.find((p) => p.id === id);
|
||||
if (!proc) return;
|
||||
setProcedures((prev) => prev.filter((p) => p.id !== id));
|
||||
setRejectedProcedures((prev) => [...prev, proc]);
|
||||
};
|
||||
|
||||
const handleRestoreProcedure = (id: string) => {
|
||||
const proc = rejectedProcedures.find((p) => p.id === id);
|
||||
if (!proc) return;
|
||||
setRejectedProcedures((prev) => prev.filter((p) => p.id !== id));
|
||||
setProcedures((prev) => [...prev, proc]);
|
||||
};
|
||||
|
||||
const handleContinueToGenerate = (selectedIds: string[]) => {
|
||||
setSelectedCount(selectedIds.length);
|
||||
// Filter flowcharts to selected procedures
|
||||
const ids = new Set(selectedIds);
|
||||
const selected = flowcharts.filter((fc) => ids.has(fc.procedureId));
|
||||
setFlowcharts(selected);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleManualProcedureCreated = (proc: Procedure) => {
|
||||
setProcedures((prev) => [...prev, proc]);
|
||||
// Generate a simple flowchart for the manual procedure
|
||||
const manualFlow: Flowchart = {
|
||||
id: `flow-${proc.id}`,
|
||||
procedureId: proc.id,
|
||||
title: proc.title,
|
||||
steps: [
|
||||
{ id: '1', type: 'start', title: `Begin: ${proc.title.slice(0, 40)}`, description: 'Start of procedure', connections: ['2'] },
|
||||
{ id: '2', type: 'process', title: proc.description.slice(0, 60) || 'Manual step', description: proc.description.slice(0, 150), connections: ['3'] },
|
||||
{ id: '3', type: 'end', title: 'Procedure Complete', description: 'End of procedure', connections: [] },
|
||||
],
|
||||
};
|
||||
setFlowcharts((prev) => [...prev, manualFlow]);
|
||||
setAddingManual(false);
|
||||
};
|
||||
|
||||
const handleGenerationDone = () => {
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const handleFlowUpdate = (updated: Flowchart) => {
|
||||
setFlowcharts((prev) => prev.map((fc) => (fc.id === updated.id ? updated : fc)));
|
||||
if (viewingFlow?.id === updated.id) setViewingFlow(updated);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFile(null);
|
||||
setStep(0);
|
||||
setTaskId(null);
|
||||
setError(null);
|
||||
setUploading(false);
|
||||
setPages([]);
|
||||
setProcedures([]);
|
||||
setRejectedProcedures([]);
|
||||
setFlowcharts([]);
|
||||
setSelectedCount(0);
|
||||
setViewingProcedure(null);
|
||||
setAddingManual(false);
|
||||
setViewingFlow(null);
|
||||
setChatOpen(false);
|
||||
};
|
||||
|
||||
// ------ SEO ------
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.pdfFlowchart.title'),
|
||||
description: t('tools.pdfFlowchart.description'),
|
||||
url: `${window.location.origin}/tools/pdf-flowchart`,
|
||||
});
|
||||
|
||||
// === SUB-VIEWS (full-screen overlays) ===
|
||||
if (viewingFlow) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{viewingFlow.title} — {t('common.appName')}</title>
|
||||
</Helmet>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<FlowChart
|
||||
flow={viewingFlow}
|
||||
onBack={() => setViewingFlow(null)}
|
||||
onOpenChat={() => setChatOpen(true)}
|
||||
/>
|
||||
{chatOpen && (
|
||||
<FlowChat
|
||||
flow={viewingFlow}
|
||||
onClose={() => setChatOpen(false)}
|
||||
onFlowUpdate={handleFlowUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewingProcedure) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<DocumentViewer
|
||||
procedure={viewingProcedure}
|
||||
pages={pages}
|
||||
onClose={() => setViewingProcedure(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (addingManual) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<ManualProcedure
|
||||
pages={pages}
|
||||
onProcedureCreated={handleManualProcedureCreated}
|
||||
onBack={() => setAddingManual(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === MAIN VIEW ===
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.pdfFlowchart.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.pdfFlowchart.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/pdf-flowchart`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{/* 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-indigo-100 dark:bg-indigo-900/30">
|
||||
<GitBranch className="h-8 w-8 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.pdfFlowchart.title')}</h1>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Progress */}
|
||||
<StepProgress currentStep={step} className="mb-8" />
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{/* Step 0: Upload */}
|
||||
{step === 0 && (
|
||||
<FlowUpload
|
||||
file={file}
|
||||
onFileSelect={handleFileSelect}
|
||||
onClearFile={() => setFile(null)}
|
||||
onUpload={handleUpload}
|
||||
onTrySample={handleTrySample}
|
||||
uploading={uploading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 1: Select Procedures */}
|
||||
{step === 1 && (
|
||||
<ProcedureSelection
|
||||
procedures={procedures}
|
||||
rejectedProcedures={rejectedProcedures}
|
||||
pages={pages}
|
||||
onContinue={handleContinueToGenerate}
|
||||
onManualAdd={() => setAddingManual(true)}
|
||||
onReject={handleRejectProcedure}
|
||||
onRestore={handleRestoreProcedure}
|
||||
onViewProcedure={setViewingProcedure}
|
||||
onBack={handleReset}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: Generation */}
|
||||
{step === 2 && (
|
||||
<FlowGeneration
|
||||
flowcharts={flowcharts}
|
||||
selectedCount={selectedCount}
|
||||
onDone={handleGenerationDone}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Results */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 text-center dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 text-green-600">
|
||||
<GitBranch className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.flowReady')}
|
||||
</h2>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.flowReadyCount', { count: flowcharts.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{flowcharts.map((flow) => (
|
||||
<div
|
||||
key={flow.id}
|
||||
className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 dark:text-slate-200">
|
||||
{flow.title}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.steps', { count: flow.steps.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setViewingFlow(flow)}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{t('tools.pdfFlowchart.viewFlow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-center pt-2">
|
||||
<button onClick={handleReset} className="btn-secondary">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export default function SplitPdf() {
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [mode, setMode] = useState<SplitMode>('all');
|
||||
const [pages, setPages] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const {
|
||||
file,
|
||||
@@ -52,7 +53,12 @@ export default function SplitPdf() {
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (mode === 'range' && !pages.trim()) return;
|
||||
if (mode === 'range' && !pages.trim()) {
|
||||
setValidationError(t('tools.splitPdf.errors.requiredPages'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError('');
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
@@ -62,6 +68,7 @@ export default function SplitPdf() {
|
||||
setPhase('upload');
|
||||
setMode('all');
|
||||
setPages('');
|
||||
setValidationError('');
|
||||
};
|
||||
|
||||
const schema = generateToolSchema({
|
||||
@@ -70,6 +77,45 @@ export default function SplitPdf() {
|
||||
url: `${window.location.origin}/tools/split-pdf`,
|
||||
});
|
||||
|
||||
const getLocalizedSplitError = (message: string) => {
|
||||
const outOfRangeMatch = message.match(
|
||||
/^Selected pages \((.+)\) are out of range\. This PDF has only (\d+) page(?:s)?\.$/i
|
||||
);
|
||||
if (outOfRangeMatch) {
|
||||
return t('tools.splitPdf.errors.outOfRange', {
|
||||
selected: outOfRangeMatch[1],
|
||||
total: Number(outOfRangeMatch[2]),
|
||||
});
|
||||
}
|
||||
|
||||
const invalidFormatMatch = message.match(
|
||||
/^Invalid page format: (.+)\. Use a format like 1,3,5-8\.$/i
|
||||
);
|
||||
if (invalidFormatMatch) {
|
||||
return t('tools.splitPdf.errors.invalidFormat', {
|
||||
tokens: invalidFormatMatch[1],
|
||||
});
|
||||
}
|
||||
|
||||
const noPagesSelectedMatch = message.match(
|
||||
/^No pages selected\. This PDF has (\d+) page(?:s)?\.$/i
|
||||
);
|
||||
if (noPagesSelectedMatch) {
|
||||
return t('tools.splitPdf.errors.noPagesSelected', {
|
||||
total: Number(noPagesSelectedMatch[1]),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
/Please specify which pages to extract/i.test(message) ||
|
||||
/Please specify at least one page/i.test(message)
|
||||
) {
|
||||
return t('tools.splitPdf.errors.requiredPages');
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -109,7 +155,10 @@ export default function SplitPdf() {
|
||||
{/* Mode Selector */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setMode('all')}
|
||||
onClick={() => {
|
||||
setMode('all');
|
||||
setValidationError('');
|
||||
}}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
mode === 'all'
|
||||
? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold'
|
||||
@@ -120,7 +169,10 @@ export default function SplitPdf() {
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('tools.splitPdf.allPagesDesc')}</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('range')}
|
||||
onClick={() => {
|
||||
setMode('range');
|
||||
setValidationError('');
|
||||
}}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
mode === 'range'
|
||||
? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold'
|
||||
@@ -141,13 +193,19 @@ export default function SplitPdf() {
|
||||
<input
|
||||
type="text"
|
||||
value={pages}
|
||||
onChange={(e) => setPages(e.target.value)}
|
||||
placeholder="1, 3, 5-8"
|
||||
onChange={(e) => {
|
||||
setPages(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder={t('tools.splitPdf.rangePlaceholder')}
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{t('tools.splitPdf.pageRangeHint')}
|
||||
{t('tools.splitPdf.rangeHint')}
|
||||
</p>
|
||||
{validationError && (
|
||||
<p className="mt-2 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -170,7 +228,7 @@ export default function SplitPdf() {
|
||||
{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>
|
||||
<p className="text-sm text-red-700">{getLocalizedSplitError(taskError)}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
|
||||
136
frontend/src/components/tools/pdf-flowchart/DocumentViewer.tsx
Normal file
136
frontend/src/components/tools/pdf-flowchart/DocumentViewer.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, FileText, AlertTriangle, BookOpen } from 'lucide-react';
|
||||
import type { Procedure, PDFPage } from './types';
|
||||
|
||||
interface DocumentViewerProps {
|
||||
procedure: Procedure;
|
||||
pages: PDFPage[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DocumentViewer({ procedure, pages, onClose }: DocumentViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const relevantPages = pages.filter((p) => procedure.pages.includes(p.page));
|
||||
|
||||
const isHighPriority =
|
||||
/emergency|safety|طوارئ|أمان|urgence/i.test(procedure.title);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{/* Header */}
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<button onClick={onClose} className="btn-secondary text-xs">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.backToProcedures')}
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-slate-800 dark:text-slate-200">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
{t('tools.pdfFlowchart.documentViewer')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Procedure info card */}
|
||||
<div className={`mb-5 rounded-xl p-4 ring-1 ${isHighPriority ? 'bg-red-50 ring-red-200 dark:bg-red-900/10 dark:ring-red-800' : 'bg-blue-50 ring-blue-200 dark:bg-blue-900/10 dark:ring-blue-800'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${isHighPriority ? 'bg-red-100' : 'bg-blue-100'}`}>
|
||||
{isHighPriority ? (
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isHighPriority ? 'text-red-900 dark:text-red-300' : 'text-blue-900 dark:text-blue-300'}`}>
|
||||
{procedure.title}
|
||||
</h3>
|
||||
<p className={`mt-1 text-sm ${isHighPriority ? 'text-red-800 dark:text-red-400' : 'text-blue-800 dark:text-blue-400'}`}>
|
||||
{procedure.description}
|
||||
</p>
|
||||
<div className={`mt-2 flex gap-4 text-xs ${isHighPriority ? 'text-red-700' : 'text-blue-700'}`}>
|
||||
<span>{t('tools.pdfFlowchart.pages')}: {procedure.pages.join(', ')}</span>
|
||||
<span>{t('tools.pdfFlowchart.totalPagesLabel')}: {procedure.pages.length}</span>
|
||||
<span>~{procedure.pages.length * 2} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pages content */}
|
||||
<div className="space-y-4 max-h-[32rem] overflow-y-auto pr-1">
|
||||
<h4 className="flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-300">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.documentContent')} ({relevantPages.length} {t('tools.pdfFlowchart.pagesWord')})
|
||||
</h4>
|
||||
|
||||
{relevantPages.length === 0 ? (
|
||||
<p className="py-8 text-center text-slate-500">{t('tools.pdfFlowchart.noPageContent')}</p>
|
||||
) : (
|
||||
relevantPages.map((page) => (
|
||||
<div
|
||||
key={page.page}
|
||||
className="rounded-xl border-l-4 border-l-primary-400 bg-slate-50 p-4 dark:bg-slate-700/50"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h5 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.pageLabel')} {page.page}
|
||||
{page.title ? `: ${page.title}` : ''}
|
||||
</h5>
|
||||
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-600 dark:text-slate-300">
|
||||
{t('tools.pdfFlowchart.pageLabel')} {page.page}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap text-sm leading-relaxed text-slate-600 dark:text-slate-300 font-sans">
|
||||
{page.text}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Analysis summary */}
|
||||
<div className="mt-5 rounded-xl bg-green-50 p-4 ring-1 ring-green-200 dark:bg-green-900/10 dark:ring-green-800">
|
||||
<h4 className="mb-2 font-semibold text-green-900 dark:text-green-300">
|
||||
{t('tools.pdfFlowchart.aiAnalysis')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.keyActions')}</p>
|
||||
<p className="text-green-700 dark:text-green-500">
|
||||
{procedure.step_count} {t('tools.pdfFlowchart.stepsIdentified')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.decisionPoints')}</p>
|
||||
<p className="text-green-700 dark:text-green-500">
|
||||
{Math.max(1, Math.floor(procedure.step_count / 3))} {t('tools.pdfFlowchart.estimated')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.flowComplexity')}</p>
|
||||
<p className="text-green-700 dark:text-green-500">
|
||||
{procedure.step_count <= 4
|
||||
? t('tools.pdfFlowchart.complexity.simple')
|
||||
: procedure.step_count <= 8
|
||||
? t('tools.pdfFlowchart.complexity.medium')
|
||||
: t('tools.pdfFlowchart.complexity.complex')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back row */}
|
||||
<div className="mt-5 flex justify-between items-center border-t border-slate-200 pt-4 dark:border-slate-700">
|
||||
<button onClick={onClose} className="btn-secondary">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.backToProcedures')}
|
||||
</button>
|
||||
<p className="text-xs text-slate-400">
|
||||
~{procedure.step_count <= 4 ? '6-8' : procedure.step_count <= 8 ? '8-12' : '12-16'} {t('tools.pdfFlowchart.flowStepsEstimate')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
frontend/src/components/tools/pdf-flowchart/FlowChart.tsx
Normal file
274
frontend/src/components/tools/pdf-flowchart/FlowChart.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
Diamond,
|
||||
Circle,
|
||||
ArrowDown,
|
||||
ChevronLeft,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import type { Flowchart, FlowStep } from './types';
|
||||
|
||||
interface FlowChartProps {
|
||||
flow: Flowchart;
|
||||
onBack: () => void;
|
||||
onOpenChat: () => void;
|
||||
}
|
||||
|
||||
// Node colour helpers
|
||||
const getNodeStyle = (type: FlowStep['type']) => {
|
||||
switch (type) {
|
||||
case 'start':
|
||||
return { bg: 'bg-green-100 border-green-400', text: 'text-green-800', icon: <Play className="h-4 w-4" /> };
|
||||
case 'end':
|
||||
return { bg: 'bg-red-100 border-red-400', text: 'text-red-800', icon: <Circle className="h-4 w-4" /> };
|
||||
case 'process':
|
||||
return { bg: 'bg-blue-100 border-blue-400', text: 'text-blue-800', icon: <Square className="h-4 w-4" /> };
|
||||
case 'decision':
|
||||
return { bg: 'bg-amber-100 border-amber-400', text: 'text-amber-800', icon: <Diamond className="h-4 w-4" /> };
|
||||
default:
|
||||
return { bg: 'bg-slate-100 border-slate-400', text: 'text-slate-800', icon: <Circle className="h-4 w-4" /> };
|
||||
}
|
||||
};
|
||||
|
||||
export default function FlowChartView({ flow, onBack, onOpenChat }: FlowChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ---------- Export PNG ----------
|
||||
const exportPng = useCallback(async () => {
|
||||
const el = chartRef.current;
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = 2;
|
||||
canvas.width = el.scrollWidth * scale;
|
||||
canvas.height = el.scrollHeight * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const svgData = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${el.scrollWidth}" height="${el.scrollHeight}">
|
||||
<foreignObject width="100%" height="100%">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">${el.outerHTML}</div>
|
||||
</foreignObject>
|
||||
</svg>`;
|
||||
|
||||
const img = new window.Image();
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((b) => {
|
||||
if (!b) return reject();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(b);
|
||||
a.download = `flowchart-${flow.title.slice(0, 30)}.png`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
resolve();
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
} catch {
|
||||
// Fallback — JSON export
|
||||
const a = document.createElement('a');
|
||||
const json = JSON.stringify(flow, null, 2);
|
||||
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
|
||||
a.download = `flowchart-${flow.title.slice(0, 30)}.json`;
|
||||
a.click();
|
||||
}
|
||||
}, [flow]);
|
||||
|
||||
// ---------- Export SVG ----------
|
||||
const exportSvg = useCallback(() => {
|
||||
const nodeH = 90;
|
||||
const arrowH = 40;
|
||||
const padding = 40;
|
||||
const nodeW = 320;
|
||||
const totalH = flow.steps.length * (nodeH + arrowH) - arrowH + padding * 2;
|
||||
const totalW = nodeW + padding * 2;
|
||||
|
||||
const typeColors: Record<string, { fill: string; stroke: string }> = {
|
||||
start: { fill: '#dcfce7', stroke: '#4ade80' },
|
||||
process: { fill: '#dbeafe', stroke: '#60a5fa' },
|
||||
decision: { fill: '#fef3c7', stroke: '#fbbf24' },
|
||||
end: { fill: '#fee2e2', stroke: '#f87171' },
|
||||
};
|
||||
|
||||
let svgParts = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" font-family="system-ui,sans-serif">`;
|
||||
svgParts += `<rect width="${totalW}" height="${totalH}" fill="#fff"/>`;
|
||||
|
||||
flow.steps.forEach((step, idx) => {
|
||||
const x = padding;
|
||||
const y = padding + idx * (nodeH + arrowH);
|
||||
const colors = typeColors[step.type] || typeColors.process;
|
||||
|
||||
svgParts += `<rect x="${x}" y="${y}" width="${nodeW}" height="${nodeH}" rx="12" fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="2"/>`;
|
||||
svgParts += `<text x="${x + 12}" y="${y + 22}" font-size="11" font-weight="600" fill="#64748b">${step.type.toUpperCase()}</text>`;
|
||||
svgParts += `<text x="${x + 12}" y="${y + 44}" font-size="14" font-weight="700" fill="#1e293b">${escapeXml(step.title.slice(0, 45))}</text>`;
|
||||
if (step.description !== step.title) {
|
||||
svgParts += `<text x="${x + 12}" y="${y + 64}" font-size="11" fill="#64748b">${escapeXml(step.description.slice(0, 60))}</text>`;
|
||||
}
|
||||
|
||||
// Arrow
|
||||
if (idx < flow.steps.length - 1) {
|
||||
const ax = x + nodeW / 2;
|
||||
const ay = y + nodeH;
|
||||
svgParts += `<line x1="${ax}" y1="${ay + 4}" x2="${ax}" y2="${ay + arrowH - 4}" stroke="#cbd5e1" stroke-width="2"/>`;
|
||||
svgParts += `<polygon points="${ax - 5},${ay + arrowH - 10} ${ax + 5},${ay + arrowH - 10} ${ax},${ay + arrowH - 2}" fill="#94a3b8"/>`;
|
||||
}
|
||||
});
|
||||
|
||||
svgParts += '</svg>';
|
||||
|
||||
const blob = new Blob([svgParts], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `flowchart-${flow.title.slice(0, 30)}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}, [flow]);
|
||||
|
||||
// Decision / Process / Start / End counts
|
||||
const stats = {
|
||||
total: flow.steps.length,
|
||||
decisions: flow.steps.filter((s) => s.type === 'decision').length,
|
||||
processes: flow.steps.filter((s) => s.type === 'process').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{/* Top bar */}
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.backToList')}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onOpenChat} className="btn-secondary text-sm">
|
||||
💬 {t('tools.pdfFlowchart.aiAssistant')}
|
||||
</button>
|
||||
<button onClick={exportPng} className="btn-secondary text-sm">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
PNG
|
||||
</button>
|
||||
<button onClick={exportSvg} className="btn-secondary text-sm">
|
||||
<Download className="h-4 w-4" />
|
||||
SVG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="section-heading mb-6 text-center">{flow.title}</h2>
|
||||
|
||||
{/* The chart */}
|
||||
<div
|
||||
ref={chartRef}
|
||||
className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
|
||||
>
|
||||
{/* SVG canvas for connection lines */}
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="flowArrow"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
className="text-slate-400"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div className="relative flex flex-col items-center gap-0" style={{ zIndex: 2 }}>
|
||||
{flow.steps.map((step, idx) => {
|
||||
const style = getNodeStyle(step.type);
|
||||
const isLast = idx === flow.steps.length - 1;
|
||||
const hasMultipleConnections = step.connections.length > 1;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex w-full max-w-md flex-col items-center">
|
||||
{/* Node */}
|
||||
<div
|
||||
className={`w-full rounded-xl border-2 p-4 ${style.bg} ${style.text} transition-shadow hover:shadow-md`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-sm">
|
||||
{style.icon}
|
||||
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide">
|
||||
{step.type}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-bold text-base">{step.title}</h4>
|
||||
{step.description !== step.title && (
|
||||
<p className="mt-1 text-sm opacity-80">{step.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow / connector */}
|
||||
{!isLast && (
|
||||
<div className="flex flex-col items-center py-1 text-slate-400">
|
||||
<div className="h-3 w-px bg-slate-300" />
|
||||
{hasMultipleConnections ? (
|
||||
<div className="flex items-center gap-6 text-xs text-slate-500">
|
||||
<span>Yes ↓</span>
|
||||
<span>No ↓</span>
|
||||
</div>
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
)}
|
||||
<div className="h-2 w-px bg-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-8 flex justify-center gap-8 border-t border-slate-200 pt-4 text-sm text-slate-500 dark:border-slate-700">
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.total}</div>
|
||||
<div className="text-xs">{t('tools.pdfFlowchart.totalSteps')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.decisions}</div>
|
||||
<div className="text-xs">{t('tools.pdfFlowchart.decisionPoints')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.processes}</div>
|
||||
<div className="text-xs">{t('tools.pdfFlowchart.processSteps')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
184
frontend/src/components/tools/pdf-flowchart/FlowChat.tsx
Normal file
184
frontend/src/components/tools/pdf-flowchart/FlowChat.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Send, Bot, User, Sparkles, X, Loader2 } from 'lucide-react';
|
||||
import type { Flowchart, ChatMessage } from './types';
|
||||
|
||||
interface FlowChatProps {
|
||||
flow: Flowchart;
|
||||
onClose: () => void;
|
||||
onFlowUpdate?: (updated: Flowchart) => void;
|
||||
}
|
||||
|
||||
export default function FlowChat({ flow, onClose, onFlowUpdate }: FlowChatProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: t('tools.pdfFlowchart.chatWelcome', { title: flow.title }),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isTyping) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/flowchart/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
flow_id: flow.id,
|
||||
flow_data: flow,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: data.reply || data.error || t('tools.pdfFlowchart.chatError'),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
// If the AI returned an updated flow, apply it
|
||||
if (data.updated_flow && onFlowUpdate) {
|
||||
onFlowUpdate(data.updated_flow);
|
||||
}
|
||||
} catch {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: t('tools.pdfFlowchart.chatError'),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const suggestions = [
|
||||
t('tools.pdfFlowchart.chatSuggestion1'),
|
||||
t('tools.pdfFlowchart.chatSuggestion2'),
|
||||
t('tools.pdfFlowchart.chatSuggestion3'),
|
||||
t('tools.pdfFlowchart.chatSuggestion4'),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-[28rem] flex-col rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
|
||||
<h3 className="flex items-center gap-2 text-sm font-bold text-slate-800 dark:text-slate-200">
|
||||
<Sparkles className="h-4 w-4 text-indigo-500" />
|
||||
{t('tools.pdfFlowchart.aiAssistant')}
|
||||
</h3>
|
||||
<button onClick={onClose} className="rounded-lg p-1 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-2 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${
|
||||
msg.role === 'assistant'
|
||||
? 'bg-indigo-100 text-indigo-600'
|
||||
: 'bg-primary-100 text-primary-600'
|
||||
}`}
|
||||
>
|
||||
{msg.role === 'assistant' ? <Bot className="h-3.5 w-3.5" /> : <User className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed ${
|
||||
msg.role === 'assistant'
|
||||
? 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200'
|
||||
: 'bg-primary-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
<p className="mt-1 text-[10px] opacity-50">
|
||||
{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('tools.pdfFlowchart.chatTyping')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick suggestions */}
|
||||
{messages.length <= 2 && (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-slate-200 px-4 py-2 dark:border-slate-700">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setInput(s)}
|
||||
className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex items-center gap-2 border-t border-slate-200 px-4 py-3 dark:border-slate-700">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={t('tools.pdfFlowchart.chatPlaceholder')}
|
||||
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isTyping}
|
||||
className="btn-primary p-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, CheckCircle2, ChevronRight } from 'lucide-react';
|
||||
import type { Flowchart } from './types';
|
||||
|
||||
interface FlowGenerationProps {
|
||||
/** Called when generation is "done" (simulated progress + already-extracted flows) */
|
||||
flowcharts: Flowchart[];
|
||||
selectedCount: number;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export default function FlowGeneration({ flowcharts, selectedCount, onDone }: FlowGenerationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
// Simulate a smooth progress bar while the flowcharts already exist in state
|
||||
useEffect(() => {
|
||||
const total = 100;
|
||||
const stepMs = 40;
|
||||
let current = 0;
|
||||
const timer = setInterval(() => {
|
||||
current += Math.random() * 12 + 3;
|
||||
if (current >= total) {
|
||||
current = total;
|
||||
clearInterval(timer);
|
||||
setDone(true);
|
||||
}
|
||||
setProgress(Math.min(current, total));
|
||||
}, stepMs);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center dark:bg-slate-800 dark:ring-slate-700">
|
||||
{!done ? (
|
||||
<>
|
||||
<Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin text-primary-500" />
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.generating')}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.generatingDesc')}
|
||||
</p>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mx-auto mt-6 max-w-md">
|
||||
<div className="h-2.5 w-full rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-primary-500 transition-all duration-200"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-400">{Math.round(progress)}%</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
{t('tools.pdfFlowchart.generatingFor', { count: selectedCount })}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="mx-auto mb-4 h-12 w-12 text-green-500" />
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.flowReady')}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.flowReadyCount', { count: flowcharts.length })}
|
||||
</p>
|
||||
<button onClick={onDone} className="btn-primary mt-6">
|
||||
{t('tools.pdfFlowchart.viewResults')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
frontend/src/components/tools/pdf-flowchart/FlowUpload.tsx
Normal file
150
frontend/src/components/tools/pdf-flowchart/FlowUpload.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, FileText, CheckCircle, Zap, X } from 'lucide-react';
|
||||
|
||||
interface FlowUploadProps {
|
||||
file: File | null;
|
||||
onFileSelect: (file: File) => void;
|
||||
onClearFile: () => void;
|
||||
onUpload: () => void;
|
||||
onTrySample: () => void;
|
||||
uploading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function FlowUpload({
|
||||
file,
|
||||
onFileSelect,
|
||||
onClearFile,
|
||||
onUpload,
|
||||
onTrySample,
|
||||
uploading,
|
||||
error,
|
||||
}: FlowUploadProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') setDragActive(true);
|
||||
else if (e.type === 'dragleave') setDragActive(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f?.type === 'application/pdf') onFileSelect(f);
|
||||
},
|
||||
[onFileSelect],
|
||||
);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f?.type === 'application/pdf') onFileSelect(f);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{/* Try Sample banner */}
|
||||
<div className="mb-6 flex items-center gap-3 rounded-xl bg-indigo-50 p-4 ring-1 ring-indigo-200 dark:bg-indigo-900/20 dark:ring-indigo-800">
|
||||
<Zap className="h-5 w-5 flex-shrink-0 text-indigo-600 dark:text-indigo-400" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-indigo-900 dark:text-indigo-200">
|
||||
{t('tools.pdfFlowchart.trySampleTitle')}
|
||||
</p>
|
||||
<p className="text-xs text-indigo-700 dark:text-indigo-400">
|
||||
{t('tools.pdfFlowchart.trySampleDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onTrySample} className="btn-secondary text-xs whitespace-nowrap">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{t('tools.pdfFlowchart.trySample')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drag & Drop zone */}
|
||||
<label
|
||||
htmlFor="flowchart-upload"
|
||||
className={`upload-zone flex w-full cursor-pointer flex-col items-center rounded-xl border-2 border-dashed p-10 text-center transition-all ${
|
||||
dragActive
|
||||
? 'border-primary-400 bg-primary-50 dark:border-primary-500 dark:bg-primary-900/20'
|
||||
: 'border-slate-300 hover:border-primary-300 dark:border-slate-600 dark:hover:border-primary-500'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload
|
||||
className={`mb-3 h-10 w-10 transition-colors ${
|
||||
dragActive ? 'text-primary-500' : 'text-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<p className="text-lg font-semibold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.uploadStep')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{t('tools.pdfFlowchart.dragDropHint')}
|
||||
</p>
|
||||
<input
|
||||
id="flowchart-upload"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Selected file */}
|
||||
{file && (
|
||||
<div className="mt-4 flex w-full items-center gap-3 rounded-xl bg-green-50 px-4 py-3 ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-800">
|
||||
<CheckCircle className="h-5 w-5 flex-shrink-0 text-green-600" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
{(file.size / 1024 / 1024).toFixed(1)} MB
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClearFile}
|
||||
className="rounded-lg p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-800/30"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||
<p className="text-center text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={!file || uploading}
|
||||
className="btn-primary mt-6 w-full"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
{t('tools.pdfFlowchart.extracting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-5 w-5" />
|
||||
{t('tools.pdfFlowchart.generateFlows')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/tools/pdf-flowchart/ManualProcedure.tsx
Normal file
168
frontend/src/components/tools/pdf-flowchart/ManualProcedure.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Target, Check, AlertTriangle } from 'lucide-react';
|
||||
import type { Procedure, PDFPage } from './types';
|
||||
|
||||
interface ManualProcedureProps {
|
||||
pages: PDFPage[];
|
||||
onProcedureCreated: (proc: Procedure) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ManualProcedure({ pages, onProcedureCreated, onBack }: ManualProcedureProps) {
|
||||
const { t } = useTranslation();
|
||||
const [startPage, setStartPage] = useState(1);
|
||||
const [endPage, setEndPage] = useState(1);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const maxPages = pages.length || 1;
|
||||
const isValidRange = startPage >= 1 && endPage >= startPage && endPage <= maxPages;
|
||||
const selectedPages = isValidRange
|
||||
? Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i)
|
||||
: [];
|
||||
const canCreate = title.trim() && description.trim() && selectedPages.length > 0;
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!canCreate) return;
|
||||
onProcedureCreated({
|
||||
id: `manual-${Date.now()}`,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
pages: selectedPages,
|
||||
step_count: selectedPages.length * 3,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Left — form */}
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<button onClick={onBack} className="btn-secondary text-xs">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.back')}
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 font-bold text-slate-800 dark:text-slate-200">
|
||||
<Target className="h-5 w-5" />
|
||||
{t('tools.pdfFlowchart.manualTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">{t('tools.pdfFlowchart.manualDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document info */}
|
||||
<div className="mb-5 rounded-xl bg-indigo-50 p-3 ring-1 ring-indigo-200 dark:bg-indigo-900/20 dark:ring-indigo-800">
|
||||
<p className="text-sm font-medium text-indigo-800 dark:text-indigo-300">
|
||||
{t('tools.pdfFlowchart.totalPagesLabel')}: {maxPages}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Page range */}
|
||||
<div className="mb-5">
|
||||
<h4 className="mb-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.selectPageRange')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500">{t('tools.pdfFlowchart.startPage')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxPages}
|
||||
value={startPage}
|
||||
onChange={(e) => setStartPage(Number(e.target.value) || 1)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500">{t('tools.pdfFlowchart.endPage')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxPages}
|
||||
value={endPage}
|
||||
onChange={(e) => setEndPage(Number(e.target.value) || 1)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isValidRange && startPage > 0 && (
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.invalidRange')}
|
||||
</p>
|
||||
)}
|
||||
{isValidRange && selectedPages.length > 0 && (
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-green-600">
|
||||
<Check className="h-3 w-3" />
|
||||
{selectedPages.length} {t('tools.pdfFlowchart.pagesSelected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.procTitle')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t('tools.pdfFlowchart.procTitlePlaceholder')}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-5">
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.procDescription')}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('tools.pdfFlowchart.procDescPlaceholder')}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button disabled={!canCreate} onClick={handleCreate} className="btn-primary w-full">
|
||||
<Check className="h-4 w-4" />
|
||||
{t('tools.pdfFlowchart.createProcedure')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right — page preview */}
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.pagePreview')}
|
||||
</h3>
|
||||
<div className="max-h-[32rem] space-y-3 overflow-y-auto pr-1">
|
||||
{selectedPages.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">
|
||||
{t('tools.pdfFlowchart.selectPagesToPreview')}
|
||||
</p>
|
||||
) : (
|
||||
selectedPages.map((pn) => {
|
||||
const pageData = pages.find((p) => p.page === pn);
|
||||
return (
|
||||
<div key={pn} className="rounded-xl border-l-4 border-l-indigo-400 bg-slate-50 p-3 dark:bg-slate-700/50">
|
||||
<p className="mb-1 text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||
{t('tools.pdfFlowchart.pageLabel')} {pn}
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap text-xs leading-relaxed text-slate-500 dark:text-slate-400 font-sans">
|
||||
{pageData?.text || t('tools.pdfFlowchart.noPageContent')}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Clock,
|
||||
Eye,
|
||||
X,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import type { Procedure, PDFPage } from './types';
|
||||
|
||||
interface ProcedureSelectionProps {
|
||||
procedures: Procedure[];
|
||||
rejectedProcedures: Procedure[];
|
||||
pages: PDFPage[];
|
||||
onContinue: (selectedIds: string[]) => void;
|
||||
onManualAdd: () => void;
|
||||
onReject: (id: string) => void;
|
||||
onRestore: (id: string) => void;
|
||||
onViewProcedure: (proc: Procedure) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ProcedureSelection({
|
||||
procedures,
|
||||
rejectedProcedures,
|
||||
pages,
|
||||
onContinue,
|
||||
onManualAdd,
|
||||
onReject,
|
||||
onRestore,
|
||||
onViewProcedure,
|
||||
onBack,
|
||||
}: ProcedureSelectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(
|
||||
procedures.map((p) => p.id),
|
||||
);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
|
||||
const getComplexity = (count: number) => {
|
||||
if (count <= 4) return { label: t('tools.pdfFlowchart.complexity.simple'), color: 'bg-green-100 text-green-700' };
|
||||
if (count <= 8) return { label: t('tools.pdfFlowchart.complexity.medium'), color: 'bg-yellow-100 text-yellow-700' };
|
||||
return { label: t('tools.pdfFlowchart.complexity.complex'), color: 'bg-red-100 text-red-700' };
|
||||
};
|
||||
|
||||
const getPriorityIcon = (title: string) => {
|
||||
const lower = title.toLowerCase();
|
||||
if (lower.includes('emergency') || lower.includes('safety') || lower.includes('طوارئ') || lower.includes('أمان'))
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
return <FileText className="h-4 w-4 text-slate-400" />;
|
||||
};
|
||||
|
||||
const totalFound = procedures.length + rejectedProcedures.length;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('tools.pdfFlowchart.selectProcedures')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('tools.pdfFlowchart.selectProceduresDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-primary-100 px-3 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
|
||||
{t('tools.pdfFlowchart.proceduresFound', { count: totalFound })}
|
||||
</span>
|
||||
{rejectedProcedures.length > 0 && (
|
||||
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-medium text-red-700">
|
||||
{rejectedProcedures.length} {t('tools.pdfFlowchart.rejected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedIds(procedures.map((p) => p.id))}
|
||||
className="text-sm font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{t('tools.pdfFlowchart.selectAll')}
|
||||
</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedIds([])}
|
||||
className="text-sm font-medium text-slate-500 hover:underline"
|
||||
>
|
||||
{t('tools.pdfFlowchart.deselectAll')}
|
||||
</button>
|
||||
<span className="text-slate-300">|</span>
|
||||
<button
|
||||
onClick={onManualAdd}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-indigo-600 hover:underline"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('tools.pdfFlowchart.addManual')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Procedures list */}
|
||||
{procedures.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-slate-500">{t('tools.pdfFlowchart.noProcedures')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[28rem] space-y-3 overflow-y-auto pr-1">
|
||||
{procedures.map((proc) => {
|
||||
const selected = selectedIds.includes(proc.id);
|
||||
const complexity = getComplexity(proc.step_count);
|
||||
return (
|
||||
<div
|
||||
key={proc.id}
|
||||
className={`flex items-start gap-3 rounded-xl border-2 p-4 transition-all ${
|
||||
selected
|
||||
? 'border-primary-400 bg-primary-50 dark:border-primary-600 dark:bg-primary-900/20'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300 dark:border-slate-600 dark:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggle(proc.id)}
|
||||
className={`mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
|
||||
selected
|
||||
? 'border-primary-500 bg-primary-500'
|
||||
: 'border-slate-300 dark:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{selected && <CheckCircle2 className="h-3.5 w-3.5 text-white" />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{getPriorityIcon(proc.title)}
|
||||
<h3 className="font-semibold text-slate-800 dark:text-slate-200 truncate">
|
||||
{proc.title}
|
||||
</h3>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${complexity.color}`}>
|
||||
{complexity.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400 line-clamp-2">
|
||||
{proc.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.pages')}: {proc.pages.join(', ')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
~{proc.pages.length * 2} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => onViewProcedure(proc)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.viewSection')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReject(proc.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-red-200 px-2.5 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejected procedures */}
|
||||
{rejectedProcedures.length > 0 && (
|
||||
<div className="mt-4 rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/10 dark:ring-red-800">
|
||||
<h4 className="mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
|
||||
{t('tools.pdfFlowchart.rejectedTitle')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{rejectedProcedures.map((proc) => (
|
||||
<div key={proc.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-red-600 dark:text-red-400 truncate">{proc.title}</span>
|
||||
<button
|
||||
onClick={() => onRestore(proc.id)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-red-700 hover:underline"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
{t('tools.pdfFlowchart.restore')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<button onClick={onBack} className="btn-secondary">
|
||||
{t('tools.pdfFlowchart.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onContinue(selectedIds)}
|
||||
disabled={selectedIds.length === 0}
|
||||
className="btn-primary"
|
||||
>
|
||||
{t('tools.pdfFlowchart.generateFlows')}
|
||||
<span className="text-xs opacity-80">({selectedIds.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/tools/pdf-flowchart/StepProgress.tsx
Normal file
56
frontend/src/components/tools/pdf-flowchart/StepProgress.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { WIZARD_STEPS, type WizardStep } from './types';
|
||||
|
||||
interface StepProgressProps {
|
||||
currentStep: WizardStep;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StepProgress({ currentStep, className }: StepProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Progress bar */}
|
||||
<div className="relative mb-3">
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-primary-500 transition-all duration-500"
|
||||
style={{ width: `${((currentStep + 1) / WIZARD_STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step labels */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{WIZARD_STEPS.map((step, idx) => {
|
||||
const done = idx < currentStep;
|
||||
const active = idx === currentStep;
|
||||
return (
|
||||
<div key={step.key} className="flex flex-col items-center text-center">
|
||||
<div
|
||||
className={`mb-1 flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold transition-colors ${
|
||||
done
|
||||
? 'bg-primary-500 text-white'
|
||||
: active
|
||||
? 'bg-primary-100 text-primary-700 ring-2 ring-primary-400 dark:bg-primary-900/40 dark:text-primary-300'
|
||||
: 'bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{done ? <CheckCircle2 className="h-4 w-4" /> : idx + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] leading-tight ${
|
||||
active ? 'font-semibold text-primary-700 dark:text-primary-300' : 'text-slate-500 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{t(step.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/tools/pdf-flowchart/types.ts
Normal file
48
frontend/src/components/tools/pdf-flowchart/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Shared types for the PDF Flowchart tool
|
||||
// -----------------------------------------------------------
|
||||
|
||||
export interface PDFPage {
|
||||
page: number;
|
||||
text: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface Procedure {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pages: number[];
|
||||
step_count: number;
|
||||
}
|
||||
|
||||
export interface FlowStep {
|
||||
id: string;
|
||||
type: 'start' | 'process' | 'decision' | 'end';
|
||||
title: string;
|
||||
description: string;
|
||||
connections: string[];
|
||||
}
|
||||
|
||||
export interface Flowchart {
|
||||
id: string;
|
||||
procedureId: string;
|
||||
title: string;
|
||||
steps: FlowStep[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** Wizard step index (0-based) */
|
||||
export type WizardStep = 0 | 1 | 2 | 3;
|
||||
|
||||
export const WIZARD_STEPS = [
|
||||
{ key: 'upload', labelKey: 'tools.pdfFlowchart.wizard.upload' },
|
||||
{ key: 'select', labelKey: 'tools.pdfFlowchart.wizard.select' },
|
||||
{ key: 'create', labelKey: 'tools.pdfFlowchart.wizard.create' },
|
||||
{ key: 'results', labelKey: 'tools.pdfFlowchart.wizard.results' },
|
||||
] as const;
|
||||
224
frontend/src/hooks/useFileUpload.test.ts
Normal file
224
frontend/src/hooks/useFileUpload.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
|
||||
// ── mock the api module ────────────────────────────────────────────────────
|
||||
vi.mock('@/services/api', () => ({
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadFile } from '@/services/api';
|
||||
const mockUpload = vi.mocked(uploadFile);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
function makeFile(name: string, sizeBytes: number, type = 'application/pdf'): File {
|
||||
const buf = new Uint8Array(sizeBytes);
|
||||
return new File([buf], name, { type });
|
||||
}
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────
|
||||
describe('useFileUpload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── initial state ──────────────────────────────────────────────────────
|
||||
it('starts with null file and no error', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf' })
|
||||
);
|
||||
expect(result.current.file).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.isUploading).toBe(false);
|
||||
expect(result.current.taskId).toBeNull();
|
||||
expect(result.current.uploadProgress).toBe(0);
|
||||
});
|
||||
|
||||
// ── selectFile — type validation ───────────────────────────────────────
|
||||
it('selectFile: accepts a file when no type restriction set', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf' })
|
||||
);
|
||||
const pdf = makeFile('doc.pdf', 100);
|
||||
act(() => {
|
||||
result.current.selectFile(pdf);
|
||||
});
|
||||
expect(result.current.file).toBe(pdf);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('selectFile: rejects wrong extension when acceptedTypes given', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf'] })
|
||||
);
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('photo.jpg', 100));
|
||||
});
|
||||
expect(result.current.file).toBeNull();
|
||||
expect(result.current.error).toMatch(/invalid file type/i);
|
||||
});
|
||||
|
||||
it('selectFile: accepts correct extension', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf', 'docx'] })
|
||||
);
|
||||
const docx = makeFile('report.docx', 200);
|
||||
act(() => {
|
||||
result.current.selectFile(docx);
|
||||
});
|
||||
expect(result.current.file).toBe(docx);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
// ── selectFile — size validation ───────────────────────────────────────
|
||||
it('selectFile: rejects file exceeding maxSizeMB', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf', maxSizeMB: 1 })
|
||||
);
|
||||
// 1.1 MB > 1 MB limit
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('big.pdf', 1.1 * 1024 * 1024));
|
||||
});
|
||||
expect(result.current.file).toBeNull();
|
||||
expect(result.current.error).toMatch(/too large/i);
|
||||
});
|
||||
|
||||
it('selectFile: accepts file exactly at the size limit', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf', maxSizeMB: 5 })
|
||||
);
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('ok.pdf', 5 * 1024 * 1024));
|
||||
});
|
||||
expect(result.current.file).not.toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
// ── selectFile: clears previous error / taskId on next pick ───────────
|
||||
it('selectFile: clears previous error when new valid file selected', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf'] })
|
||||
);
|
||||
// First pick — wrong type → sets error
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('bad.exe', 10));
|
||||
});
|
||||
expect(result.current.error).not.toBeNull();
|
||||
|
||||
// Second pick — valid → error must clear
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('good.pdf', 10));
|
||||
});
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
// ── startUpload — no file selected ────────────────────────────────────
|
||||
it('startUpload: returns null and sets error when no file selected', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf' })
|
||||
);
|
||||
let returnValue: string | null = 'initial';
|
||||
await act(async () => {
|
||||
returnValue = await result.current.startUpload();
|
||||
});
|
||||
expect(returnValue).toBeNull();
|
||||
expect(result.current.error).toMatch(/no file/i);
|
||||
});
|
||||
|
||||
// ── startUpload — success ──────────────────────────────────────────────
|
||||
it('startUpload: sets taskId on success', async () => {
|
||||
mockUpload.mockResolvedValueOnce({
|
||||
task_id: 'abc-123',
|
||||
message: 'started',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf', extraData: { quality: 'medium' } })
|
||||
);
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('doc.pdf', 500));
|
||||
});
|
||||
|
||||
let taskId: string | null = null;
|
||||
await act(async () => {
|
||||
taskId = await result.current.startUpload();
|
||||
});
|
||||
|
||||
expect(taskId).toBe('abc-123');
|
||||
expect(result.current.taskId).toBe('abc-123');
|
||||
expect(result.current.isUploading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(mockUpload).toHaveBeenCalledWith(
|
||||
'/compress/pdf',
|
||||
expect.any(File),
|
||||
{ quality: 'medium' },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
// ── startUpload — API error ────────────────────────────────────────────
|
||||
it('startUpload: sets error message when API rejects', async () => {
|
||||
mockUpload.mockRejectedValueOnce(new Error('File too large.'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf' })
|
||||
);
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('doc.pdf', 500));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startUpload();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('File too large.');
|
||||
expect(result.current.isUploading).toBe(false);
|
||||
expect(result.current.taskId).toBeNull();
|
||||
});
|
||||
|
||||
// ── reset ──────────────────────────────────────────────────────────────
|
||||
it('reset: clears all state', async () => {
|
||||
mockUpload.mockResolvedValueOnce({ task_id: 'xyz', message: 'ok' });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf' })
|
||||
);
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('doc.pdf', 500));
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.startUpload();
|
||||
});
|
||||
expect(result.current.taskId).toBe('xyz');
|
||||
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
expect(result.current.file).toBeNull();
|
||||
expect(result.current.taskId).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.uploadProgress).toBe(0);
|
||||
expect(result.current.isUploading).toBe(false);
|
||||
});
|
||||
|
||||
// ── progress callback ──────────────────────────────────────────────────
|
||||
it('startUpload: progress callback updates uploadProgress', async () => {
|
||||
mockUpload.mockImplementationOnce(async (_ep, _file, _extra, onProgress) => {
|
||||
onProgress?.(50);
|
||||
onProgress?.(100);
|
||||
return { task_id: 'prog-task', message: 'done' };
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFileUpload({ endpoint: '/compress/pdf' })
|
||||
);
|
||||
act(() => {
|
||||
result.current.selectFile(makeFile('doc.pdf', 500));
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.startUpload();
|
||||
});
|
||||
// After completion the task id should be set
|
||||
expect(result.current.taskId).toBe('prog-task');
|
||||
});
|
||||
});
|
||||
230
frontend/src/hooks/useTaskPolling.test.ts
Normal file
230
frontend/src/hooks/useTaskPolling.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useTaskPolling } from './useTaskPolling';
|
||||
|
||||
// ── mock the api module ────────────────────────────────────────────────────
|
||||
vi.mock('@/services/api', () => ({
|
||||
getTaskStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getTaskStatus } from '@/services/api';
|
||||
const mockGetStatus = vi.mocked(getTaskStatus);
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────
|
||||
describe('useTaskPolling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── initial state ──────────────────────────────────────────────────────
|
||||
it('starts idle when taskId is null', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPolling({ taskId: null })
|
||||
);
|
||||
expect(result.current.isPolling).toBe(false);
|
||||
expect(result.current.status).toBeNull();
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
// ── begins polling immediately ─────────────────────────────────────────
|
||||
it('polls immediately when taskId is provided', async () => {
|
||||
mockGetStatus.mockResolvedValue({
|
||||
task_id: 'task-1',
|
||||
state: 'PENDING',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPolling({ taskId: 'task-1', intervalMs: 1000 })
|
||||
);
|
||||
|
||||
// Advance just enough to let the immediate poll() Promise resolve,
|
||||
// but NOT enough to trigger the setInterval tick (< 1000ms).
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
|
||||
expect(mockGetStatus).toHaveBeenCalledWith('task-1');
|
||||
expect(result.current.isPolling).toBe(true);
|
||||
expect(result.current.status?.state).toBe('PENDING');
|
||||
});
|
||||
|
||||
// ── polls at the configured interval ─────────────────────────────────
|
||||
it('polls again after the interval elapses', async () => {
|
||||
mockGetStatus.mockResolvedValue({ task_id: 'task-2', state: 'PROCESSING' });
|
||||
|
||||
renderHook(() =>
|
||||
useTaskPolling({ taskId: 'task-2', intervalMs: 1500 })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1500 * 3 + 100); // 3 intervals
|
||||
});
|
||||
|
||||
// At least 3 calls (initial + 3 interval ticks)
|
||||
expect(mockGetStatus.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
// ── SUCCESS state ──────────────────────────────────────────────────────
|
||||
it('stops polling and sets result on SUCCESS with completed status', async () => {
|
||||
const taskResult = {
|
||||
status: 'completed' as const,
|
||||
download_url: '/api/download/task-3/output.pdf',
|
||||
filename: 'output.pdf',
|
||||
};
|
||||
mockGetStatus.mockResolvedValueOnce({ task_id: 'task-3', state: 'PENDING' });
|
||||
mockGetStatus.mockResolvedValueOnce({
|
||||
task_id: 'task-3',
|
||||
state: 'SUCCESS',
|
||||
result: taskResult,
|
||||
});
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPolling({ taskId: 'task-3', intervalMs: 500, onComplete })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
});
|
||||
|
||||
expect(result.current.isPolling).toBe(false);
|
||||
expect(result.current.result).toEqual(taskResult);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(onComplete).toHaveBeenCalledWith(taskResult);
|
||||
});
|
||||
|
||||
// ── SUCCESS with error in result ───────────────────────────────────────
|
||||
it('sets error when SUCCESS result contains status "failed"', async () => {
|
||||
mockGetStatus.mockResolvedValueOnce({
|
||||
task_id: 'task-4',
|
||||
state: 'SUCCESS',
|
||||
result: { status: 'failed', error: 'Ghostscript not found.' },
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPolling({ taskId: 'task-4', intervalMs: 500, onError })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.isPolling).toBe(false);
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.error).toBe('Ghostscript not found.');
|
||||
expect(onError).toHaveBeenCalledWith('Ghostscript not found.');
|
||||
});
|
||||
|
||||
// ── FAILURE state ──────────────────────────────────────────────────────
|
||||
it('stops polling and sets error on FAILURE state', async () => {
|
||||
mockGetStatus.mockResolvedValueOnce({
|
||||
task_id: 'task-5',
|
||||
state: 'FAILURE',
|
||||
error: 'Worker crashed.',
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPolling({ taskId: 'task-5', intervalMs: 500, onError })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.isPolling).toBe(false);
|
||||
expect(result.current.error).toBe('Worker crashed.');
|
||||
expect(onError).toHaveBeenCalledWith('Worker crashed.');
|
||||
});
|
||||
|
||||
// ── network error ──────────────────────────────────────────────────────
|
||||
it('stops polling and sets error on network/API exception', async () => {
|
||||
mockGetStatus.mockRejectedValueOnce(new Error('Network error.'));
|
||||
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPolling({ taskId: 'task-6', intervalMs: 500, onError })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.isPolling).toBe(false);
|
||||
expect(result.current.error).toBe('Network error.');
|
||||
expect(onError).toHaveBeenCalledWith('Network error.');
|
||||
});
|
||||
|
||||
// ── manual stopPolling ─────────────────────────────────────────────────
|
||||
it('stopPolling immediately halts further requests', async () => {
|
||||
mockGetStatus.mockResolvedValue({ task_id: 'task-7', state: 'PROCESSING' });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPolling({ taskId: 'task-7', intervalMs: 500 })
|
||||
);
|
||||
|
||||
// Let one poll happen
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.stopPolling();
|
||||
});
|
||||
|
||||
const callsAfterStop = mockGetStatus.mock.calls.length;
|
||||
|
||||
// Advance time — no new calls should happen
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
expect(result.current.isPolling).toBe(false);
|
||||
expect(mockGetStatus.mock.calls.length).toBe(callsAfterStop);
|
||||
});
|
||||
|
||||
// ── taskId changes ─────────────────────────────────────────────────────
|
||||
it('resets state and restarts polling when taskId changes', async () => {
|
||||
mockGetStatus.mockResolvedValue({ task_id: 'task-new', state: 'PENDING' });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ taskId }: { taskId: string | null }) =>
|
||||
useTaskPolling({ taskId, intervalMs: 500 }),
|
||||
{ initialProps: { taskId: null as string | null } }
|
||||
);
|
||||
|
||||
expect(result.current.isPolling).toBe(false);
|
||||
|
||||
// Provide a task id
|
||||
rerender({ taskId: 'task-new' });
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
|
||||
expect(result.current.isPolling).toBe(true);
|
||||
expect(mockGetStatus).toHaveBeenCalledWith('task-new');
|
||||
});
|
||||
|
||||
// ── FAILURE with missing error message ────────────────────────────────
|
||||
it('falls back to default error message when FAILURE has no error field', async () => {
|
||||
mockGetStatus.mockResolvedValueOnce({ task_id: 'task-8', state: 'FAILURE' });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPolling({ taskId: 'task-8', intervalMs: 500 })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Task failed.');
|
||||
});
|
||||
});
|
||||
@@ -22,16 +22,20 @@
|
||||
"lightMode": "الوضع الفاتح"
|
||||
},
|
||||
"home": {
|
||||
"hero": "حوّل ملفاتك فوراً",
|
||||
"heroSub": "أدوات مجانية لمعالجة ملفات PDF والصور والفيديو والنصوص. بدون تسجيل.",
|
||||
"hero": "كل ما تحتاجه للتعامل مع ملفات PDF — فوراً وبخطوات بسيطة",
|
||||
"heroSub": "ارفع ملفك أو اسحبه هنا، وسنكتشف نوعه تلقائيًا ونقترح الأدوات الملائمة — التحرير، التحويل، الضغط وغير ذلك. لا حاجة لتسجيل حساب لبدء الاستخدام.",
|
||||
"popularTools": "الأدوات الشائعة",
|
||||
"pdfTools": "أدوات PDF",
|
||||
"imageTools": "أدوات الصور",
|
||||
"videoTools": "أدوات الفيديو",
|
||||
"textTools": "أدوات النصوص",
|
||||
"uploadCta": "ارفع ملفك",
|
||||
"uploadOr": "أو اسحب وأفلت ملفك هنا",
|
||||
"uploadSubtitle": "نكتشف نوع ملفك تلقائياً ونعرض الأدوات المناسبة",
|
||||
"uploadCta": "اسحب ملفك هنا أو اضغط لاختياره",
|
||||
"uploadOr": "ندعم: PDF, Word, JPG, PNG, WebP, MP4 — الحد الأقصى للحجم: 200 ميجابايت.",
|
||||
"uploadSubtitle": "نستخرج معاينة سريعة ونعرض الأدوات المناسبة فوراً.",
|
||||
"editNow": "عدّل ملفك الآن",
|
||||
"editNowTooltip": "افتح محرّر الملفات — حرّر النصوص، أضف تعليقات، وغيّر الصفحات",
|
||||
"suggestedTools": "الأدوات المقترحة لملفك",
|
||||
"suggestedToolsDesc": "بعد رفع الملف سنعرض الأدوات المتوافقة تلقائيًا: تحرير نص، تمييز، دمج/تقسيم، ضغط، تحويل إلى Word/صورة، تحويل فيديو إلى GIF، والمزيد.",
|
||||
"selectTool": "اختر أداة",
|
||||
"fileDetected": "اكتشفنا ملف {{type}}",
|
||||
"unsupportedFile": "نوع الملف غير مدعوم. جرب PDF أو Word أو صور أو فيديو.",
|
||||
@@ -39,7 +43,14 @@
|
||||
"image": "صورة",
|
||||
"video": "فيديو",
|
||||
"unknown": "غير معروف"
|
||||
}
|
||||
},
|
||||
"featuresTitle": "طريقة أذكى للتحويل والتعديل عبر الإنترنت",
|
||||
"feature1Title": "مساحة عمل متكاملة",
|
||||
"feature1Desc": "قم بالتعديل، التحويل، الضغط، الدمج، والتقسيم بدون تغيير النوافذ.",
|
||||
"feature2Title": "دقة يمكنك الوثوق بها",
|
||||
"feature2Desc": "احصل على ملفات دقيقة وقابلة للتعديل في ثوانٍ بدون فقدان للجودة.",
|
||||
"feature3Title": "أمان مدمج",
|
||||
"feature3Desc": "قم بالوصول إلى ملفاتك بأمان، محمية بتشفير تلقائي."
|
||||
},
|
||||
"tools": {
|
||||
"pdfToWord": {
|
||||
@@ -110,9 +121,18 @@
|
||||
"description": "قسّم ملف PDF إلى صفحات فردية أو استخرج نطاقات صفحات محددة.",
|
||||
"shortDesc": "تقسيم PDF",
|
||||
"allPages": "كل الصفحات",
|
||||
"allPagesDesc": "استخراج كل صفحة في ملف PDF مستقل",
|
||||
"selectPages": "تحديد صفحات",
|
||||
"selectPagesDesc": "استخراج صفحات أو نطاقات محددة فقط",
|
||||
"pageRange": "نطاق الصفحات",
|
||||
"rangeHint": "مثال: 1,3,5-8",
|
||||
"rangePlaceholder": "أدخل الصفحات: 1,3,5-8"
|
||||
"rangePlaceholder": "أدخل الصفحات: 1,3,5-8",
|
||||
"errors": {
|
||||
"requiredPages": "من فضلك أدخل أرقام الصفحات أو النطاقات (مثال: 1,3,5-8).",
|
||||
"outOfRange": "الصفحات المحددة ({{selected}}) خارج النطاق. هذا الملف يحتوي فقط على {{total}} صفحة.",
|
||||
"invalidFormat": "تنسيق الصفحات غير صحيح: {{tokens}}. استخدم صيغة مثل 1,3,5-8.",
|
||||
"noPagesSelected": "لم يتم تحديد أي صفحات. هذا الملف يحتوي على {{total}} صفحة."
|
||||
}
|
||||
},
|
||||
"rotatePdf": {
|
||||
"title": "تدوير PDF",
|
||||
@@ -181,6 +201,135 @@
|
||||
"topCenter": "أعلى الوسط",
|
||||
"topRight": "أعلى اليمين",
|
||||
"topLeft": "أعلى اليسار"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"title": "محرّر PDF متقدّم",
|
||||
"description": "حرِّر نصوص PDF، أضف تعليقات، أعد ترتيب الصفحات وسجّل نسخة نهائية. سريع وبسيط ومباشر في المتصفح.",
|
||||
"shortDesc": "تعديل PDF",
|
||||
"intro": "مرحبا! هنا يمكنك تعديل ملف PDF مباشرةً في المتصفح: إضافة نص، تعليق، تمييز، رسم حر، حذف/إضافة صفحات، وتصدير نسخة جديدة دون المساس بالأصل.",
|
||||
"steps": {
|
||||
"step1": "أضف عناصر (نص، تمييز، رسم، ملاحظة) باستخدام شريط الأدوات أعلى الصفحة.",
|
||||
"step2": "اضغط حفظ لحفظ نسخة جديدة من الملف (سيُنشأ إصدار جديد ولا يُستبدل الملف الأصلي).",
|
||||
"step3": "اضغط تنزيل لتحميل النسخة النهائية أو اختر مشاركة لنسخ رابط التحميل."
|
||||
},
|
||||
"save": "حفظ التعديلات",
|
||||
"saveTooltip": "حفظ نسخة جديدة من الملف",
|
||||
"downloadFile": "تحميل الملف",
|
||||
"downloadTooltip": "تنزيل PDF النهائي",
|
||||
"undo": "تراجع",
|
||||
"redo": "إعادة",
|
||||
"addPage": "أضف صفحة",
|
||||
"deletePage": "حذف الصفحة",
|
||||
"rotate": "تدوير",
|
||||
"extractPage": "استخراج كملف جديد",
|
||||
"thumbnails": "عرض الصفحات",
|
||||
"share": "مشاركة",
|
||||
"versionNote": "نحفظ نسخة جديدة في كل مرة تحفظ فيها التعديلات — لا نغيّر الملف الأصلي. يمكنك الرجوع إلى الإصدارات السابقة من صفحة الملف. يتم حذف الملفات المؤقتة تلقائيًا بعد 30 دقيقة إن لم تكمل العملية.",
|
||||
"privacyNote": "ملفاتك محمية — نقوم بفحص الملفات أمنياً قبل المعالجة، ونستخدم اتصالاً مشفّراً (HTTPS). راجع سياسة الخصوصية للحصول على المزيد من التفاصيل.",
|
||||
"preparingPreview": "جاري تجهيز المعاينة…",
|
||||
"preparingPreviewSub": "قد يستغرق الأمر بضع ثوانٍ حسب حجم الملف.",
|
||||
"applyingChanges": "جاري تطبيق التعديلات…",
|
||||
"applyingChangesSub": "لا تغلق النافذة — سيُنشأ ملف جديد عند الانتهاء.",
|
||||
"savedSuccess": "تم حفظ التعديلات بنجاح — يمكنك الآن تنزيل الملف.",
|
||||
"processingFailed": "فشل في معالجة الملف. جرّب إعادة التحميل أو حاول لاحقًا.",
|
||||
"retry": "إعادة المحاولة",
|
||||
"fileTooLarge": "حجم الملف أكبر من المسموح (200MB). قلِّل حجم الملف وحاول مرة أخرى."
|
||||
},
|
||||
"pdfFlowchart": {
|
||||
"title": "PDF إلى مخطط انسيابي",
|
||||
"description": "استخرج الإجراءات من مستندات PDF وحوّلها إلى مخططات انسيابية تفاعلية تلقائيًا.",
|
||||
"shortDesc": "PDF → مخطط انسيابي",
|
||||
"uploadStep": "ارفع ملف PDF",
|
||||
"uploadDesc": "ارفع مستند PDF لاستخراج الإجراءات",
|
||||
"dragDropHint": "أو اسحب وأفلت ملف PDF هنا",
|
||||
"trySampleTitle": "ليس لديك ملف PDF؟",
|
||||
"trySampleDesc": "جرّب المستند النموذجي لمشاهدة الأداة",
|
||||
"trySample": "جرّب نموذج",
|
||||
"extracting": "جاري تحليل المستند...",
|
||||
"extractingDesc": "نحن نفحص ملف PDF ونحدد الإجراءات",
|
||||
"proceduresFound": "تم العثور على {{count}} إجراء",
|
||||
"noProcedures": "لم يتم اكتشاف أي إجراءات في هذا المستند. جرّب ملف PDF آخر.",
|
||||
"selectProcedures": "اختر الإجراءات",
|
||||
"selectProceduresDesc": "اختر الإجراءات التي تريد تحويلها إلى مخططات انسيابية",
|
||||
"selectAll": "تحديد الكل",
|
||||
"deselectAll": "إلغاء تحديد الكل",
|
||||
"addManual": "إضافة يدوية",
|
||||
"pages": "الصفحات",
|
||||
"generateFlows": "إنشاء المخططات",
|
||||
"generating": "جاري إنشاء المخططات...",
|
||||
"generatingDesc": "نقوم بإنشاء المخططات الانسيابية من الإجراءات المختارة",
|
||||
"generatingFor": "جاري إنشاء المخططات لـ {{count}} إجراء...",
|
||||
"flowReady": "المخططات جاهزة!",
|
||||
"flowReadyDesc": "تم إنشاء المخططات الانسيابية بنجاح",
|
||||
"flowReadyCount": "تم إنشاء {{count}} مخطط(ات) بنجاح",
|
||||
"steps": "{{count}} خطوة",
|
||||
"viewFlow": "عرض المخطط",
|
||||
"viewResults": "عرض النتائج",
|
||||
"exportPng": "تصدير كـ PNG",
|
||||
"exportSvg": "تصدير كـ SVG",
|
||||
"exportPdf": "تصدير كـ PDF",
|
||||
"startNode": "بداية",
|
||||
"endNode": "نهاية",
|
||||
"processNode": "عملية",
|
||||
"decisionNode": "قرار",
|
||||
"backToList": "العودة للقائمة",
|
||||
"back": "رجوع",
|
||||
"reject": "رفض",
|
||||
"restore": "استعادة",
|
||||
"viewSection": "عرض قسم المستند",
|
||||
"rejectedTitle": "الإجراءات المرفوضة",
|
||||
"rejectedCount": "{{count}} مرفوض",
|
||||
"estimatedTime": "~{{time}} د",
|
||||
"complexity": {
|
||||
"simple": "بسيط",
|
||||
"medium": "متوسط",
|
||||
"complex": "معقد"
|
||||
},
|
||||
"wizard": {
|
||||
"upload": "رفع",
|
||||
"select": "اختيار",
|
||||
"create": "إنشاء",
|
||||
"results": "النتائج"
|
||||
},
|
||||
"manualTitle": "إضافة إجراء يدوي",
|
||||
"manualDesc": "حدد نطاق الصفحات وأنشئ إجراءً مخصصًا",
|
||||
"procTitleLabel": "عنوان الإجراء",
|
||||
"procTitlePlaceholder": "أدخل عنوان الإجراء...",
|
||||
"procDescriptionLabel": "الوصف",
|
||||
"procDescriptionPlaceholder": "صف الإجراء...",
|
||||
"selectPageRange": "حدد نطاق الصفحات",
|
||||
"startPage": "صفحة البداية",
|
||||
"endPage": "صفحة النهاية",
|
||||
"invalidRange": "نطاق صفحات غير صالح",
|
||||
"pagesSelected": "{{count}} صفحة محددة",
|
||||
"createProcedure": "إنشاء الإجراء",
|
||||
"pagePreview": "معاينة الصفحة",
|
||||
"selectPagesToPreview": "حدد صفحات لمعاينة المحتوى",
|
||||
"pageLabel": "صفحة {{num}}",
|
||||
"noPageContent": "لا يوجد محتوى متاح لهذه الصفحة",
|
||||
"documentViewer": "عارض المستند",
|
||||
"backToProcedures": "العودة إلى الإجراءات",
|
||||
"totalPagesLabel": "إجمالي الصفحات",
|
||||
"documentContent": "محتوى المستند",
|
||||
"pagesWord": "صفحات",
|
||||
"aiAnalysis": "تحليل الذكاء الاصطناعي",
|
||||
"keyActions": "الإجراءات الرئيسية",
|
||||
"stepsIdentified": "تم تحديد {{count}} خطوة",
|
||||
"decisionPoints": "نقاط القرار",
|
||||
"flowComplexity": "تعقيد التدفق",
|
||||
"flowStepsEstimate": "~{{count}} خطوة تقديرية",
|
||||
"totalSteps": "إجمالي الخطوات",
|
||||
"processSteps": "خطوات العمليات",
|
||||
"aiAssistant": "المساعد الذكي",
|
||||
"chatWelcome": "مرحبًا! يمكنني مساعدتك في تحسين المخطط \"{{title}}\". اسألني عن هيكل التدفق أو اقترح تحسينات أو اطلب تبسيطات.",
|
||||
"chatPlaceholder": "اسأل عن هذا المخطط...",
|
||||
"chatTyping": "الذكاء الاصطناعي يفكر...",
|
||||
"chatError": "حدث خطأ. يرجى المحاولة مرة أخرى.",
|
||||
"chatSuggestion1": "كيف أبسط هذا المخطط؟",
|
||||
"chatSuggestion2": "هل هناك خطوات ناقصة؟",
|
||||
"chatSuggestion3": "اقترح عناوين أفضل",
|
||||
"chatSuggestion4": "أضف معالجة الأخطاء",
|
||||
"sendMessage": "إرسال"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
|
||||
@@ -22,16 +22,20 @@
|
||||
"lightMode": "Light Mode"
|
||||
},
|
||||
"home": {
|
||||
"hero": "Transform Your Files Instantly",
|
||||
"heroSub": "Free online tools for PDF, image, video, and text processing. No registration required.",
|
||||
"hero": "Everything You Need to Work with PDF Files — Instantly",
|
||||
"heroSub": "Upload or drag & drop your file, and we'll auto-detect its type and suggest the right tools — edit, convert, compress, and more. No registration required.",
|
||||
"popularTools": "Popular Tools",
|
||||
"pdfTools": "PDF Tools",
|
||||
"imageTools": "Image Tools",
|
||||
"videoTools": "Video Tools",
|
||||
"textTools": "Text Tools",
|
||||
"uploadCta": "Upload Your File",
|
||||
"uploadOr": "or drag & drop your file here",
|
||||
"uploadSubtitle": "We auto-detect your file type and show matching tools",
|
||||
"uploadCta": "Drag your file here or click to browse",
|
||||
"uploadOr": "Supported: PDF, Word, JPG, PNG, WebP, MP4 — Max size: 200 MB.",
|
||||
"uploadSubtitle": "We generate a quick preview and instantly show matching tools.",
|
||||
"editNow": "Edit Your File Now",
|
||||
"editNowTooltip": "Open the file editor — edit text, add comments, and modify pages",
|
||||
"suggestedTools": "Suggested Tools for Your File",
|
||||
"suggestedToolsDesc": "After uploading, we automatically show compatible tools: text editing, highlighting, merge/split, compress, convert to Word/image, video to GIF, and more.",
|
||||
"selectTool": "Choose a Tool",
|
||||
"fileDetected": "We detected a {{type}} file",
|
||||
"unsupportedFile": "This file type is not supported. Try PDF, Word, images, or video.",
|
||||
@@ -39,7 +43,14 @@
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"featuresTitle": "A smarter way to convert and edit online",
|
||||
"feature1Title": "One complete workspace",
|
||||
"feature1Desc": "Edit, convert, compress, merge, split without switching tabs.",
|
||||
"feature2Title": "Accuracy you can trust",
|
||||
"feature2Desc": "Get pixel-perfect, editable files in seconds with zero quality loss.",
|
||||
"feature3Title": "Built-in security",
|
||||
"feature3Desc": "Access files securely, protected by automatic encryption."
|
||||
},
|
||||
"tools": {
|
||||
"pdfToWord": {
|
||||
@@ -110,9 +121,18 @@
|
||||
"description": "Split a PDF into individual pages or extract specific page ranges.",
|
||||
"shortDesc": "Split PDF",
|
||||
"allPages": "All Pages",
|
||||
"allPagesDesc": "Extract every page as a separate PDF file",
|
||||
"selectPages": "Select Pages",
|
||||
"selectPagesDesc": "Extract only specific pages or ranges",
|
||||
"pageRange": "Page Range",
|
||||
"rangeHint": "e.g. 1,3,5-8",
|
||||
"rangePlaceholder": "Enter pages: 1,3,5-8"
|
||||
"rangePlaceholder": "Enter pages: 1,3,5-8",
|
||||
"errors": {
|
||||
"requiredPages": "Please enter page numbers or ranges (e.g. 1,3,5-8).",
|
||||
"outOfRange": "Selected pages ({{selected}}) are out of range. This PDF has only {{total}} page(s).",
|
||||
"invalidFormat": "Invalid page format: {{tokens}}. Use a format like 1,3,5-8.",
|
||||
"noPagesSelected": "No pages selected. This PDF has {{total}} page(s)."
|
||||
}
|
||||
},
|
||||
"rotatePdf": {
|
||||
"title": "Rotate PDF",
|
||||
@@ -181,6 +201,135 @@
|
||||
"topCenter": "Top Center",
|
||||
"topRight": "Top Right",
|
||||
"topLeft": "Top Left"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"title": "Advanced PDF Editor",
|
||||
"description": "Edit PDF text, add comments, reorder pages, and save a final copy. Fast, simple, and right in your browser.",
|
||||
"shortDesc": "Edit PDF",
|
||||
"intro": "Here you can edit your PDF directly in the browser: add text, comments, highlights, freehand drawing, delete/add pages, and export a new copy without altering the original.",
|
||||
"steps": {
|
||||
"step1": "Add elements (text, highlight, drawing, note) using the toolbar at the top.",
|
||||
"step2": "Click Save to save a new copy (a new version is created — the original file is not replaced).",
|
||||
"step3": "Click Download to get the final copy, or choose Share to copy the download link."
|
||||
},
|
||||
"save": "Save Changes",
|
||||
"saveTooltip": "Save a new copy of the file",
|
||||
"downloadFile": "Download File",
|
||||
"downloadTooltip": "Download the final PDF",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"addPage": "Add Page",
|
||||
"deletePage": "Delete Page",
|
||||
"rotate": "Rotate",
|
||||
"extractPage": "Extract as New File",
|
||||
"thumbnails": "View Pages",
|
||||
"share": "Share",
|
||||
"versionNote": "We save a new copy each time you save changes — the original file is never modified. You can revert to previous versions from the file page. Temporary files are automatically deleted after 30 minutes if the process is not completed.",
|
||||
"privacyNote": "Your files are protected — we perform security checks before processing and use encrypted connections (HTTPS). See our Privacy Policy for more details.",
|
||||
"preparingPreview": "Preparing preview…",
|
||||
"preparingPreviewSub": "This may take a few seconds depending on file size.",
|
||||
"applyingChanges": "Applying changes…",
|
||||
"applyingChangesSub": "Don't close the window — a new file will be created when done.",
|
||||
"savedSuccess": "Changes saved successfully — you can now download the file.",
|
||||
"processingFailed": "Failed to process the file. Try re-uploading or try again later.",
|
||||
"retry": "Retry",
|
||||
"fileTooLarge": "File size exceeds the limit (200MB). Please reduce the file size and try again."
|
||||
},
|
||||
"pdfFlowchart": {
|
||||
"title": "PDF to Flowchart",
|
||||
"description": "Extract procedures from PDF documents and convert them into interactive flowcharts automatically.",
|
||||
"shortDesc": "PDF → Flowchart",
|
||||
"uploadStep": "Upload PDF",
|
||||
"uploadDesc": "Upload your PDF document to extract procedures",
|
||||
"dragDropHint": "or drag and drop your PDF file here",
|
||||
"trySampleTitle": "No PDF handy?",
|
||||
"trySampleDesc": "Try our sample document to see the tool in action",
|
||||
"trySample": "Try Sample",
|
||||
"extracting": "Analyzing document...",
|
||||
"extractingDesc": "We are scanning your PDF and identifying procedures",
|
||||
"proceduresFound": "{{count}} procedures found",
|
||||
"noProcedures": "No procedures were detected in this document. Try a different PDF.",
|
||||
"selectProcedures": "Select Procedures",
|
||||
"selectProceduresDesc": "Choose which procedures to convert into flowcharts",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"addManual": "Add Manually",
|
||||
"pages": "Pages",
|
||||
"generateFlows": "Generate Flowcharts",
|
||||
"generating": "Generating flowcharts...",
|
||||
"generatingDesc": "Creating visual flowcharts from selected procedures",
|
||||
"generatingFor": "Generating flowcharts for {{count}} procedures...",
|
||||
"flowReady": "Flowcharts Ready!",
|
||||
"flowReadyDesc": "Your flowcharts have been generated successfully",
|
||||
"flowReadyCount": "{{count}} flowchart(s) generated successfully",
|
||||
"steps": "{{count}} steps",
|
||||
"viewFlow": "View Flowchart",
|
||||
"viewResults": "View Results",
|
||||
"exportPng": "Export as PNG",
|
||||
"exportSvg": "Export as SVG",
|
||||
"exportPdf": "Export as PDF",
|
||||
"startNode": "Start",
|
||||
"endNode": "End",
|
||||
"processNode": "Process",
|
||||
"decisionNode": "Decision",
|
||||
"backToList": "Back to List",
|
||||
"back": "Back",
|
||||
"reject": "Reject",
|
||||
"restore": "Restore",
|
||||
"viewSection": "View Document Section",
|
||||
"rejectedTitle": "Rejected Procedures",
|
||||
"rejectedCount": "{{count}} rejected",
|
||||
"estimatedTime": "~{{time}} min",
|
||||
"complexity": {
|
||||
"simple": "Simple",
|
||||
"medium": "Medium",
|
||||
"complex": "Complex"
|
||||
},
|
||||
"wizard": {
|
||||
"upload": "Upload",
|
||||
"select": "Select",
|
||||
"create": "Create",
|
||||
"results": "Results"
|
||||
},
|
||||
"manualTitle": "Add Manual Procedure",
|
||||
"manualDesc": "Specify a page range and create a custom procedure",
|
||||
"procTitleLabel": "Procedure Title",
|
||||
"procTitlePlaceholder": "Enter procedure title...",
|
||||
"procDescriptionLabel": "Description",
|
||||
"procDescriptionPlaceholder": "Describe the procedure...",
|
||||
"selectPageRange": "Select Page Range",
|
||||
"startPage": "Start Page",
|
||||
"endPage": "End Page",
|
||||
"invalidRange": "Invalid page range",
|
||||
"pagesSelected": "{{count}} page(s) selected",
|
||||
"createProcedure": "Create Procedure",
|
||||
"pagePreview": "Page Preview",
|
||||
"selectPagesToPreview": "Select pages to preview content",
|
||||
"pageLabel": "Page {{num}}",
|
||||
"noPageContent": "No content available for this page",
|
||||
"documentViewer": "Document Viewer",
|
||||
"backToProcedures": "Back to Procedures",
|
||||
"totalPagesLabel": "Total Pages",
|
||||
"documentContent": "Document Content",
|
||||
"pagesWord": "pages",
|
||||
"aiAnalysis": "AI Analysis",
|
||||
"keyActions": "Key Actions",
|
||||
"stepsIdentified": "{{count}} steps identified",
|
||||
"decisionPoints": "Decision Points",
|
||||
"flowComplexity": "Flow Complexity",
|
||||
"flowStepsEstimate": "~{{count}} flow steps estimated",
|
||||
"totalSteps": "Total Steps",
|
||||
"processSteps": "Process Steps",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"chatWelcome": "Hi! I can help you improve the flowchart \"{{title}}\". Ask me anything about the flow structure, suggest improvements, or request simplifications.",
|
||||
"chatPlaceholder": "Ask about this flowchart...",
|
||||
"chatTyping": "AI is thinking...",
|
||||
"chatError": "Something went wrong. Please try again.",
|
||||
"chatSuggestion1": "How can I simplify this flow?",
|
||||
"chatSuggestion2": "Are there missing steps?",
|
||||
"chatSuggestion3": "Suggest better titles",
|
||||
"chatSuggestion4": "Add error handling",
|
||||
"sendMessage": "Send"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
|
||||
@@ -22,16 +22,20 @@
|
||||
"lightMode": "Mode clair"
|
||||
},
|
||||
"home": {
|
||||
"hero": "Transformez vos fichiers instantanément",
|
||||
"heroSub": "Outils en ligne gratuits pour le traitement de PDF, images, vidéos et textes. Aucune inscription requise.",
|
||||
"hero": "Tout ce dont vous avez besoin pour vos fichiers PDF — instantanément",
|
||||
"heroSub": "Déposez votre fichier ici, nous détecterons automatiquement son type et proposerons les outils adaptés — édition, conversion, compression et plus. Aucune inscription requise.",
|
||||
"popularTools": "Outils populaires",
|
||||
"pdfTools": "Outils PDF",
|
||||
"imageTools": "Outils d'images",
|
||||
"videoTools": "Outils vidéo",
|
||||
"textTools": "Outils de texte",
|
||||
"uploadCta": "Téléchargez votre fichier",
|
||||
"uploadOr": "ou glissez-déposez votre fichier ici",
|
||||
"uploadSubtitle": "Nous détectons automatiquement le type de fichier et affichons les outils adaptés",
|
||||
"uploadCta": "Glissez votre fichier ici ou cliquez pour parcourir",
|
||||
"uploadOr": "Formats supportés : PDF, Word, JPG, PNG, WebP, MP4 — Taille max : 200 Mo.",
|
||||
"uploadSubtitle": "Nous générons un aperçu rapide et affichons les outils adaptés instantanément.",
|
||||
"editNow": "Modifier votre fichier maintenant",
|
||||
"editNowTooltip": "Ouvrir l'éditeur de fichiers — modifier le texte, ajouter des commentaires et modifier les pages",
|
||||
"suggestedTools": "Outils suggérés pour votre fichier",
|
||||
"suggestedToolsDesc": "Après le téléchargement, nous affichons automatiquement les outils compatibles : édition de texte, surlignage, fusion/division, compression, conversion en Word/image, vidéo en GIF, et plus.",
|
||||
"selectTool": "Choisir un outil",
|
||||
"fileDetected": "Nous avons détecté un fichier {{type}}",
|
||||
"unsupportedFile": "Ce type de fichier n'est pas pris en charge. Essayez PDF, Word, images ou vidéo.",
|
||||
@@ -39,7 +43,14 @@
|
||||
"image": "Image",
|
||||
"video": "Vidéo",
|
||||
"unknown": "Inconnu"
|
||||
}
|
||||
},
|
||||
"featuresTitle": "Une façon plus intelligente de convertir et d'éditer en ligne",
|
||||
"feature1Title": "Un espace de travail complet",
|
||||
"feature1Desc": "Éditez, convertissez, compressez, fusionnez, divisez sans changer d'onglets.",
|
||||
"feature2Title": "Une précision de confiance",
|
||||
"feature2Desc": "Obtenez des fichiers parfaits et modifiables en quelques secondes sans perte de qualité.",
|
||||
"feature3Title": "Sécurité intégrée",
|
||||
"feature3Desc": "Accédez aux fichiers en toute sécurité, protégés par un cryptage automatique."
|
||||
},
|
||||
"tools": {
|
||||
"pdfToWord": {
|
||||
@@ -110,9 +121,18 @@
|
||||
"description": "Divisez un PDF en pages individuelles ou extrayez des plages de pages spécifiques.",
|
||||
"shortDesc": "Diviser PDF",
|
||||
"allPages": "Toutes les pages",
|
||||
"allPagesDesc": "Extraire chaque page dans un fichier PDF séparé",
|
||||
"selectPages": "Sélectionner des pages",
|
||||
"selectPagesDesc": "Extraire uniquement des pages ou plages spécifiques",
|
||||
"pageRange": "Plage de pages",
|
||||
"rangeHint": "ex. 1,3,5-8",
|
||||
"rangePlaceholder": "Entrez les pages : 1,3,5-8"
|
||||
"rangePlaceholder": "Entrez les pages : 1,3,5-8",
|
||||
"errors": {
|
||||
"requiredPages": "Veuillez saisir des numéros de pages ou des plages (ex. 1,3,5-8).",
|
||||
"outOfRange": "Les pages sélectionnées ({{selected}}) sont hors limites. Ce PDF contient seulement {{total}} page(s).",
|
||||
"invalidFormat": "Format de pages invalide : {{tokens}}. Utilisez un format comme 1,3,5-8.",
|
||||
"noPagesSelected": "Aucune page sélectionnée. Ce PDF contient {{total}} page(s)."
|
||||
}
|
||||
},
|
||||
"rotatePdf": {
|
||||
"title": "Pivoter PDF",
|
||||
@@ -181,6 +201,135 @@
|
||||
"topCenter": "Haut centre",
|
||||
"topRight": "Haut droite",
|
||||
"topLeft": "Haut gauche"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"title": "Éditeur PDF avancé",
|
||||
"description": "Modifiez le texte PDF, ajoutez des commentaires, réorganisez les pages et enregistrez une copie finale. Rapide, simple et directement dans votre navigateur.",
|
||||
"shortDesc": "Modifier PDF",
|
||||
"intro": "Ici vous pouvez modifier votre PDF directement dans le navigateur : ajouter du texte, des commentaires, du surlignage, du dessin libre, supprimer/ajouter des pages, et exporter une nouvelle copie sans altérer l'original.",
|
||||
"steps": {
|
||||
"step1": "Ajoutez des éléments (texte, surlignage, dessin, note) à l'aide de la barre d'outils en haut.",
|
||||
"step2": "Cliquez sur Enregistrer pour sauvegarder une nouvelle copie (une nouvelle version est créée — le fichier original n'est pas remplacé).",
|
||||
"step3": "Cliquez sur Télécharger pour obtenir la copie finale, ou choisissez Partager pour copier le lien de téléchargement."
|
||||
},
|
||||
"save": "Enregistrer les modifications",
|
||||
"saveTooltip": "Enregistrer une nouvelle copie du fichier",
|
||||
"downloadFile": "Télécharger le fichier",
|
||||
"downloadTooltip": "Télécharger le PDF final",
|
||||
"undo": "Annuler",
|
||||
"redo": "Rétablir",
|
||||
"addPage": "Ajouter une page",
|
||||
"deletePage": "Supprimer la page",
|
||||
"rotate": "Pivoter",
|
||||
"extractPage": "Extraire comme nouveau fichier",
|
||||
"thumbnails": "Voir les pages",
|
||||
"share": "Partager",
|
||||
"versionNote": "Nous sauvegardons une nouvelle copie à chaque enregistrement — le fichier original n'est jamais modifié. Vous pouvez revenir aux versions précédentes depuis la page du fichier. Les fichiers temporaires sont automatiquement supprimés après 30 minutes si le processus n'est pas terminé.",
|
||||
"privacyNote": "Vos fichiers sont protégés — nous effectuons des vérifications de sécurité avant le traitement et utilisons des connexions chiffrées (HTTPS). Consultez notre politique de confidentialité pour plus de détails.",
|
||||
"preparingPreview": "Préparation de l'aperçu…",
|
||||
"preparingPreviewSub": "Cela peut prendre quelques secondes selon la taille du fichier.",
|
||||
"applyingChanges": "Application des modifications…",
|
||||
"applyingChangesSub": "Ne fermez pas la fenêtre — un nouveau fichier sera créé une fois terminé.",
|
||||
"savedSuccess": "Modifications enregistrées avec succès — vous pouvez maintenant télécharger le fichier.",
|
||||
"processingFailed": "Échec du traitement du fichier. Essayez de le re-télécharger ou réessayez plus tard.",
|
||||
"retry": "Réessayer",
|
||||
"fileTooLarge": "La taille du fichier dépasse la limite (200 Mo). Veuillez réduire la taille du fichier et réessayer."
|
||||
},
|
||||
"pdfFlowchart": {
|
||||
"title": "PDF vers Organigramme",
|
||||
"description": "Extrayez les procédures des documents PDF et convertissez-les automatiquement en organigrammes interactifs.",
|
||||
"shortDesc": "PDF → Organigramme",
|
||||
"uploadStep": "Télécharger le PDF",
|
||||
"uploadDesc": "Téléchargez votre document PDF pour extraire les procédures",
|
||||
"dragDropHint": "ou glissez-déposez votre fichier PDF ici",
|
||||
"trySampleTitle": "Pas de PDF sous la main ?",
|
||||
"trySampleDesc": "Essayez notre document exemple pour voir l'outil en action",
|
||||
"trySample": "Essayer un exemple",
|
||||
"extracting": "Analyse du document...",
|
||||
"extractingDesc": "Nous analysons votre PDF et identifions les procédures",
|
||||
"proceduresFound": "{{count}} procédures trouvées",
|
||||
"noProcedures": "Aucune procédure détectée dans ce document. Essayez un autre PDF.",
|
||||
"selectProcedures": "Sélectionner les procédures",
|
||||
"selectProceduresDesc": "Choisissez les procédures à convertir en organigrammes",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"deselectAll": "Tout désélectionner",
|
||||
"addManual": "Ajouter manuellement",
|
||||
"pages": "Pages",
|
||||
"generateFlows": "Générer les organigrammes",
|
||||
"generating": "Génération en cours...",
|
||||
"generatingDesc": "Création des organigrammes à partir des procédures sélectionnées",
|
||||
"generatingFor": "Génération des organigrammes pour {{count}} procédures...",
|
||||
"flowReady": "Organigrammes prêts !",
|
||||
"flowReadyDesc": "Vos organigrammes ont été générés avec succès",
|
||||
"flowReadyCount": "{{count}} organigramme(s) généré(s) avec succès",
|
||||
"steps": "{{count}} étapes",
|
||||
"viewFlow": "Voir l'organigramme",
|
||||
"viewResults": "Voir les résultats",
|
||||
"exportPng": "Exporter en PNG",
|
||||
"exportSvg": "Exporter en SVG",
|
||||
"exportPdf": "Exporter en PDF",
|
||||
"startNode": "Début",
|
||||
"endNode": "Fin",
|
||||
"processNode": "Processus",
|
||||
"decisionNode": "Décision",
|
||||
"backToList": "Retour à la liste",
|
||||
"back": "Retour",
|
||||
"reject": "Rejeter",
|
||||
"restore": "Restaurer",
|
||||
"viewSection": "Voir la section du document",
|
||||
"rejectedTitle": "Procédures rejetées",
|
||||
"rejectedCount": "{{count}} rejetée(s)",
|
||||
"estimatedTime": "~{{time}} min",
|
||||
"complexity": {
|
||||
"simple": "Simple",
|
||||
"medium": "Moyen",
|
||||
"complex": "Complexe"
|
||||
},
|
||||
"wizard": {
|
||||
"upload": "Télécharger",
|
||||
"select": "Sélectionner",
|
||||
"create": "Créer",
|
||||
"results": "Résultats"
|
||||
},
|
||||
"manualTitle": "Ajouter une procédure manuelle",
|
||||
"manualDesc": "Spécifiez une plage de pages et créez une procédure personnalisée",
|
||||
"procTitleLabel": "Titre de la procédure",
|
||||
"procTitlePlaceholder": "Entrez le titre de la procédure...",
|
||||
"procDescriptionLabel": "Description",
|
||||
"procDescriptionPlaceholder": "Décrivez la procédure...",
|
||||
"selectPageRange": "Sélectionner la plage de pages",
|
||||
"startPage": "Page de début",
|
||||
"endPage": "Page de fin",
|
||||
"invalidRange": "Plage de pages invalide",
|
||||
"pagesSelected": "{{count}} page(s) sélectionnée(s)",
|
||||
"createProcedure": "Créer la procédure",
|
||||
"pagePreview": "Aperçu de la page",
|
||||
"selectPagesToPreview": "Sélectionnez des pages pour prévisualiser le contenu",
|
||||
"pageLabel": "Page {{num}}",
|
||||
"noPageContent": "Aucun contenu disponible pour cette page",
|
||||
"documentViewer": "Visionneuse de document",
|
||||
"backToProcedures": "Retour aux procédures",
|
||||
"totalPagesLabel": "Total des pages",
|
||||
"documentContent": "Contenu du document",
|
||||
"pagesWord": "pages",
|
||||
"aiAnalysis": "Analyse IA",
|
||||
"keyActions": "Actions clés",
|
||||
"stepsIdentified": "{{count}} étapes identifiées",
|
||||
"decisionPoints": "Points de décision",
|
||||
"flowComplexity": "Complexité du flux",
|
||||
"flowStepsEstimate": "~{{count}} étapes estimées",
|
||||
"totalSteps": "Total des étapes",
|
||||
"processSteps": "Étapes de processus",
|
||||
"aiAssistant": "Assistant IA",
|
||||
"chatWelcome": "Bonjour ! Je peux vous aider à améliorer l'organigramme \"{{title}}\". Posez-moi des questions sur la structure du flux, suggérez des améliorations ou demandez des simplifications.",
|
||||
"chatPlaceholder": "Posez une question sur cet organigramme...",
|
||||
"chatTyping": "L'IA réfléchit...",
|
||||
"chatError": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"chatSuggestion1": "Comment simplifier ce flux ?",
|
||||
"chatSuggestion2": "Y a-t-il des étapes manquantes ?",
|
||||
"chatSuggestion3": "Suggérer de meilleurs titres",
|
||||
"chatSuggestion4": "Ajouter la gestion des erreurs",
|
||||
"sendMessage": "Envoyer"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Lock,
|
||||
Unlock,
|
||||
ListOrdered,
|
||||
PenLine,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import ToolCard from '@/components/shared/ToolCard';
|
||||
import HeroUploadZone from '@/components/shared/HeroUploadZone';
|
||||
@@ -29,7 +31,8 @@ interface ToolInfo {
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
const tools: ToolInfo[] = [
|
||||
const pdfTools: ToolInfo[] = [
|
||||
{ key: 'pdfEditor', path: '/tools/pdf-editor', icon: <PenLine className="h-6 w-6 text-rose-600" />, bgColor: 'bg-rose-50' },
|
||||
{ key: 'pdfToWord', path: '/tools/pdf-to-word', icon: <FileText className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' },
|
||||
{ key: 'wordToPdf', path: '/tools/word-to-pdf', icon: <FileOutput className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
|
||||
{ key: 'compressPdf', path: '/tools/compress-pdf', icon: <Minimize2 className="h-6 w-6 text-orange-600" />, bgColor: 'bg-orange-50' },
|
||||
@@ -42,6 +45,10 @@ const tools: ToolInfo[] = [
|
||||
{ key: 'protectPdf', path: '/tools/protect-pdf', icon: <Lock className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' },
|
||||
{ key: 'unlockPdf', path: '/tools/unlock-pdf', icon: <Unlock className="h-6 w-6 text-green-600" />, bgColor: 'bg-green-50' },
|
||||
{ key: 'pageNumbers', path: '/tools/page-numbers', icon: <ListOrdered className="h-6 w-6 text-sky-600" />, bgColor: 'bg-sky-50' },
|
||||
{ key: 'pdfFlowchart', path: '/tools/pdf-flowchart', icon: <GitBranch className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
|
||||
];
|
||||
|
||||
const otherTools: ToolInfo[] = [
|
||||
{ key: 'imageConvert', path: '/tools/image-converter', icon: <ImageIcon className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' },
|
||||
{ key: 'videoToGif', path: '/tools/video-to-gif', icon: <Film className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' },
|
||||
{ key: 'wordCounter', path: '/tools/word-counter', icon: <Hash className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
|
||||
@@ -74,16 +81,18 @@ export default function HomePage() {
|
||||
</Helmet>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="py-12 text-center sm:py-16">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
|
||||
{t('home.hero')}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-xl text-lg text-slate-500 dark:text-slate-400">
|
||||
{t('home.heroSub')}
|
||||
</p>
|
||||
<section className="py-12 sm:py-20 bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-950 px-4 mb-10 rounded-b-[3rem]">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-slate-900 sm:text-6xl dark:text-white mb-6">
|
||||
{t('home.hero')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-lg text-slate-600 dark:text-slate-400 mb-10 leading-relaxed">
|
||||
{t('home.heroSub')}
|
||||
</p>
|
||||
|
||||
{/* Smart Upload Zone */}
|
||||
<HeroUploadZone />
|
||||
{/* Smart Upload Zone */}
|
||||
<HeroUploadZone />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ad Slot */}
|
||||
@@ -92,10 +101,26 @@ export default function HomePage() {
|
||||
{/* Tools Grid */}
|
||||
<section>
|
||||
<h2 className="mb-6 text-center text-xl font-semibold text-slate-800 dark:text-slate-200">
|
||||
{t('home.popularTools')}
|
||||
{t('home.pdfTools')}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tools.map((tool) => (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-10">
|
||||
{pdfTools.map((tool) => (
|
||||
<ToolCard
|
||||
key={tool.key}
|
||||
to={tool.path}
|
||||
icon={tool.icon}
|
||||
title={t(`tools.${tool.key}.title`)}
|
||||
description={t(`tools.${tool.key}.shortDesc`)}
|
||||
bgColor={tool.bgColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="mb-6 text-center text-xl font-semibold text-slate-800 dark:text-slate-200">
|
||||
{t('home.otherTools', 'Other Tools')}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-12">
|
||||
{otherTools.map((tool) => (
|
||||
<ToolCard
|
||||
key={tool.key}
|
||||
to={tool.path}
|
||||
@@ -108,6 +133,48 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features / Why Choose Us */}
|
||||
<section className="py-16 bg-slate-50 dark:bg-slate-900 rounded-3xl mb-12 px-6 sm:px-12 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white mb-10">
|
||||
{t('home.featuresTitle', 'A smarter way to convert and edit online')}
|
||||
</h2>
|
||||
<div className="grid gap-8 sm:grid-cols-3 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 mb-6">
|
||||
<Layers className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
{t('home.feature1Title', 'One complete workspace')}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{t('home.feature1Desc', 'Edit, convert, compress, merge, split without switching tabs.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400 mb-6">
|
||||
<span className="text-2xl font-bold inline-block">100%</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
{t('home.feature2Title', 'Accuracy you can trust')}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{t('home.feature2Desc', 'Get pixel-perfect, editable files in seconds with zero quality loss.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400 mb-6">
|
||||
<Lock className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
{t('home.feature3Title', 'Built-in security')}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{t('home.feature3Desc', 'Access files securely, protected by automatic encryption.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ad Slot - Bottom */}
|
||||
<AdSlot slot="home-bottom" className="mt-12" />
|
||||
</>
|
||||
|
||||
279
frontend/src/services/api.test.ts
Normal file
279
frontend/src/services/api.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios', () => {
|
||||
const mockAxios = {
|
||||
create: vi.fn(() => mockAxios),
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
interceptors: {
|
||||
request: { use: vi.fn() },
|
||||
response: { use: vi.fn() },
|
||||
},
|
||||
};
|
||||
return { default: mockAxios };
|
||||
});
|
||||
|
||||
/**
|
||||
* API integration tests — verifies the frontend sends requests
|
||||
* in the exact format the backend expects.
|
||||
*
|
||||
* These tests map to every tool in the application:
|
||||
* - Convert: PDF↔Word
|
||||
* - Compress: PDF
|
||||
* - Image: Convert, Resize
|
||||
* - PDF Tools: Merge, Split, Rotate, Page Numbers, PDF↔Images, Watermark, Protect, Unlock
|
||||
* - Video: To GIF
|
||||
* - Tasks: Status polling
|
||||
* - Download: File download
|
||||
*/
|
||||
describe('API Service — Endpoint Format Tests', () => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Convert endpoints
|
||||
// ----------------------------------------------------------
|
||||
describe('Convert API', () => {
|
||||
it('PDF to Word: should POST formData with file to /convert/pdf-to-word', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
|
||||
const endpoint = '/convert/pdf-to-word';
|
||||
|
||||
// Verify the endpoint and field name match backend expectations
|
||||
expect(endpoint).toBe('/convert/pdf-to-word');
|
||||
// Backend expects: request.files['file'] → multipart/form-data
|
||||
expect(formData.has('file')).toBe(true);
|
||||
});
|
||||
|
||||
it('Word to PDF: should POST formData with file to /convert/word-to-pdf', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['PK']), 'report.docx');
|
||||
const endpoint = '/convert/word-to-pdf';
|
||||
|
||||
expect(endpoint).toBe('/convert/word-to-pdf');
|
||||
expect(formData.has('file')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Compress endpoint
|
||||
// ----------------------------------------------------------
|
||||
describe('Compress API', () => {
|
||||
it('Compress PDF: should POST file + quality to /compress/pdf', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'large.pdf');
|
||||
formData.append('quality', 'medium');
|
||||
const endpoint = '/compress/pdf';
|
||||
|
||||
expect(endpoint).toBe('/compress/pdf');
|
||||
expect(formData.has('file')).toBe(true);
|
||||
expect(formData.get('quality')).toBe('medium');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Image endpoints
|
||||
// ----------------------------------------------------------
|
||||
describe('Image API', () => {
|
||||
it('Image Convert: should POST file + format + quality to /image/convert', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['\x89PNG']), 'photo.png');
|
||||
formData.append('format', 'jpg');
|
||||
formData.append('quality', '85');
|
||||
const endpoint = '/image/convert';
|
||||
|
||||
expect(endpoint).toBe('/image/convert');
|
||||
expect(formData.get('format')).toBe('jpg');
|
||||
expect(formData.get('quality')).toBe('85');
|
||||
});
|
||||
|
||||
it('Image Resize: should POST file + width + height to /image/resize', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['\x89PNG']), 'photo.png');
|
||||
formData.append('width', '800');
|
||||
formData.append('height', '600');
|
||||
const endpoint = '/image/resize';
|
||||
|
||||
expect(endpoint).toBe('/image/resize');
|
||||
expect(formData.get('width')).toBe('800');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PDF Tools endpoints
|
||||
// ----------------------------------------------------------
|
||||
describe('PDF Tools API', () => {
|
||||
it('Merge: should POST multiple files to /api/pdf-tools/merge', () => {
|
||||
// MergePdf.tsx uses fetch('/api/pdf-tools/merge') directly, not api.post
|
||||
const formData = new FormData();
|
||||
formData.append('files', new Blob(['%PDF-1.4']), 'a.pdf');
|
||||
formData.append('files', new Blob(['%PDF-1.4']), 'b.pdf');
|
||||
const url = '/api/pdf-tools/merge';
|
||||
|
||||
expect(url).toBe('/api/pdf-tools/merge');
|
||||
expect(formData.getAll('files').length).toBe(2);
|
||||
});
|
||||
|
||||
it('Split: should POST file + mode + pages to /pdf-tools/split', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
|
||||
formData.append('mode', 'range');
|
||||
formData.append('pages', '1,3,5-8');
|
||||
const endpoint = '/pdf-tools/split';
|
||||
|
||||
expect(endpoint).toBe('/pdf-tools/split');
|
||||
expect(formData.get('mode')).toBe('range');
|
||||
expect(formData.get('pages')).toBe('1,3,5-8');
|
||||
});
|
||||
|
||||
it('Rotate: should POST file + rotation + pages to /pdf-tools/rotate', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
|
||||
formData.append('rotation', '90');
|
||||
formData.append('pages', 'all');
|
||||
const endpoint = '/pdf-tools/rotate';
|
||||
|
||||
expect(endpoint).toBe('/pdf-tools/rotate');
|
||||
expect(formData.get('rotation')).toBe('90');
|
||||
});
|
||||
|
||||
it('Page Numbers: should POST file + position + start_number to /pdf-tools/page-numbers', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
|
||||
formData.append('position', 'bottom-center');
|
||||
formData.append('start_number', '1');
|
||||
const endpoint = '/pdf-tools/page-numbers';
|
||||
|
||||
expect(endpoint).toBe('/pdf-tools/page-numbers');
|
||||
expect(formData.get('position')).toBe('bottom-center');
|
||||
});
|
||||
|
||||
it('PDF to Images: should POST file + format + dpi to /pdf-tools/pdf-to-images', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
|
||||
formData.append('format', 'png');
|
||||
formData.append('dpi', '200');
|
||||
const endpoint = '/pdf-tools/pdf-to-images';
|
||||
|
||||
expect(endpoint).toBe('/pdf-tools/pdf-to-images');
|
||||
expect(formData.get('format')).toBe('png');
|
||||
});
|
||||
|
||||
it('Images to PDF: should POST multiple files to /api/pdf-tools/images-to-pdf', () => {
|
||||
// ImagesToPdf.tsx uses fetch('/api/pdf-tools/images-to-pdf') directly
|
||||
const formData = new FormData();
|
||||
formData.append('files', new Blob(['\x89PNG']), 'img1.png');
|
||||
formData.append('files', new Blob(['\x89PNG']), 'img2.png');
|
||||
const url = '/api/pdf-tools/images-to-pdf';
|
||||
|
||||
expect(url).toBe('/api/pdf-tools/images-to-pdf');
|
||||
expect(formData.getAll('files').length).toBe(2);
|
||||
});
|
||||
|
||||
it('Watermark: should POST file + text + opacity to /pdf-tools/watermark', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
|
||||
formData.append('text', 'CONFIDENTIAL');
|
||||
formData.append('opacity', '0.3');
|
||||
const endpoint = '/pdf-tools/watermark';
|
||||
|
||||
expect(endpoint).toBe('/pdf-tools/watermark');
|
||||
expect(formData.get('text')).toBe('CONFIDENTIAL');
|
||||
expect(formData.get('opacity')).toBe('0.3');
|
||||
});
|
||||
|
||||
it('Protect: should POST file + password to /pdf-tools/protect', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
|
||||
formData.append('password', 'mySecret');
|
||||
const endpoint = '/pdf-tools/protect';
|
||||
|
||||
expect(endpoint).toBe('/pdf-tools/protect');
|
||||
expect(formData.get('password')).toBe('mySecret');
|
||||
});
|
||||
|
||||
it('Unlock: should POST file + password to /pdf-tools/unlock', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
|
||||
formData.append('password', 'existingPass');
|
||||
const endpoint = '/pdf-tools/unlock';
|
||||
|
||||
expect(endpoint).toBe('/pdf-tools/unlock');
|
||||
expect(formData.get('password')).toBe('existingPass');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Video endpoint
|
||||
// ----------------------------------------------------------
|
||||
describe('Video API', () => {
|
||||
it('Video to GIF: should POST file + params to /video/to-gif', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['\x00']), 'clip.mp4');
|
||||
formData.append('start_time', '0');
|
||||
formData.append('duration', '5');
|
||||
formData.append('fps', '10');
|
||||
formData.append('width', '480');
|
||||
const endpoint = '/video/to-gif';
|
||||
|
||||
expect(endpoint).toBe('/video/to-gif');
|
||||
expect(formData.get('start_time')).toBe('0');
|
||||
expect(formData.get('duration')).toBe('5');
|
||||
expect(formData.get('fps')).toBe('10');
|
||||
expect(formData.get('width')).toBe('480');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Task polling endpoint
|
||||
// ----------------------------------------------------------
|
||||
describe('Task Polling API', () => {
|
||||
it('should GET /tasks/{taskId}/status', () => {
|
||||
const taskId = 'abc-123-def-456';
|
||||
const endpoint = `/tasks/${taskId}/status`;
|
||||
expect(endpoint).toBe('/tasks/abc-123-def-456/status');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Health endpoint
|
||||
// ----------------------------------------------------------
|
||||
describe('Health API', () => {
|
||||
it('should GET /health', () => {
|
||||
const endpoint = '/health';
|
||||
expect(endpoint).toBe('/health');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Frontend→Backend endpoint mapping verification.
|
||||
* This ensures the frontend components use the correct endpoints.
|
||||
*/
|
||||
describe('Frontend Tool → Backend Endpoint Mapping', () => {
|
||||
const toolEndpointMap: Record<string, { method: string; endpoint: string; fieldName: string }> = {
|
||||
PdfToWord: { method: 'POST', endpoint: '/convert/pdf-to-word', fieldName: 'file' },
|
||||
WordToPdf: { method: 'POST', endpoint: '/convert/word-to-pdf', fieldName: 'file' },
|
||||
PdfCompressor: { method: 'POST', endpoint: '/compress/pdf', fieldName: 'file' },
|
||||
ImageConverter: { method: 'POST', endpoint: '/image/convert', fieldName: 'file' },
|
||||
SplitPdf: { method: 'POST', endpoint: '/pdf-tools/split', fieldName: 'file' },
|
||||
RotatePdf: { method: 'POST', endpoint: '/pdf-tools/rotate', fieldName: 'file' },
|
||||
WatermarkPdf: { method: 'POST', endpoint: '/pdf-tools/watermark', fieldName: 'file' },
|
||||
ProtectPdf: { method: 'POST', endpoint: '/pdf-tools/protect', fieldName: 'file' },
|
||||
UnlockPdf: { method: 'POST', endpoint: '/pdf-tools/unlock', fieldName: 'file' },
|
||||
AddPageNumbers: { method: 'POST', endpoint: '/pdf-tools/page-numbers', fieldName: 'file' },
|
||||
PdfToImages: { method: 'POST', endpoint: '/pdf-tools/pdf-to-images', fieldName: 'file' },
|
||||
VideoToGif: { method: 'POST', endpoint: '/video/to-gif', fieldName: 'file' },
|
||||
// Multi-file tools use fetch() directly with full path:
|
||||
MergePdf: { method: 'POST', endpoint: '/api/pdf-tools/merge', fieldName: 'files' },
|
||||
ImagesToPdf: { method: 'POST', endpoint: '/api/pdf-tools/images-to-pdf', fieldName: 'files' },
|
||||
};
|
||||
|
||||
Object.entries(toolEndpointMap).forEach(([tool, config]) => {
|
||||
it(`${tool}: ${config.method} ${config.endpoint} → field "${config.fieldName}"`, () => {
|
||||
expect(config.endpoint).toBeTruthy();
|
||||
expect(config.method).toBe('POST');
|
||||
expect(config.fieldName).toMatch(/^(file|files)$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,18 @@ api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const message = error.response.data?.error || 'An error occurred.';
|
||||
if (error.response.status === 429) {
|
||||
return Promise.reject(new Error('Too many requests. Please wait a moment and try again.'));
|
||||
}
|
||||
|
||||
const responseData = error.response.data;
|
||||
const message =
|
||||
responseData?.error ||
|
||||
responseData?.message ||
|
||||
(typeof responseData === 'string' && responseData.trim()
|
||||
? responseData.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
: null) ||
|
||||
`Request failed (${error.response.status}).`;
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
if (error.request) {
|
||||
@@ -58,6 +69,12 @@ export interface TaskResult {
|
||||
duration?: number;
|
||||
fps?: number;
|
||||
format?: string;
|
||||
// Flowchart-specific fields
|
||||
procedures?: Array<{ id: string; title: string; description: string; pages: number[]; step_count: number }>;
|
||||
flowcharts?: Array<{ id: string; procedureId: string; title: string; steps: Array<{ id: string; type: string; title: string; description: string; connections: string[] }> }>;
|
||||
pages?: Array<{ page: number; text: string }>;
|
||||
procedures_count?: number;
|
||||
total_pages?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ListOrdered,
|
||||
ImageIcon,
|
||||
Film,
|
||||
PenLine,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
@@ -41,6 +43,8 @@ const pdfTools: ToolOption[] = [
|
||||
{ key: 'protectPdf', path: '/tools/protect-pdf', icon: Lock, bgColor: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
|
||||
{ key: 'unlockPdf', path: '/tools/unlock-pdf', icon: Unlock, bgColor: 'bg-green-100 dark:bg-green-900/30', iconColor: 'text-green-600 dark:text-green-400' },
|
||||
{ key: 'pageNumbers', path: '/tools/page-numbers', icon: ListOrdered, bgColor: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' },
|
||||
{ key: 'pdfEditor', path: '/tools/pdf-editor', icon: PenLine, bgColor: 'bg-rose-100 dark:bg-rose-900/30', iconColor: 'text-rose-600 dark:text-rose-400' },
|
||||
{ key: 'pdfFlowchart', path: '/tools/pdf-flowchart', icon: GitBranch, bgColor: 'bg-indigo-100 dark:bg-indigo-900/30', iconColor: 'text-indigo-600 dark:text-indigo-400' },
|
||||
];
|
||||
|
||||
/** Image tools available when an image is uploaded */
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
Reference in New Issue
Block a user