feat: add HeroUploadZone component for file uploads and ToolSelectorModal for tool selection
- Implemented HeroUploadZone for drag-and-drop file uploads with support for various file types (PDF, images, video, Word documents). - Integrated ToolSelectorModal to display available tools based on the uploaded file type. - Added Zustand store for managing file state across routes. - Updated multiple tool components to accept files from the new upload zone. - Enhanced internationalization support with new translations for upload prompts and tool selection. - Styled the upload zone and modal for improved user experience.
This commit is contained in:
149
frontend/src/components/shared/HeroUploadZone.tsx
Normal file
149
frontend/src/components/shared/HeroUploadZone.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Upload, Sparkles } from 'lucide-react';
|
||||||
|
import ToolSelectorModal from '@/components/shared/ToolSelectorModal';
|
||||||
|
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
|
||||||
|
import type { ToolOption } from '@/utils/fileRouting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MIME types we accept on the homepage smart upload zone.
|
||||||
|
* Covers PDF, images, video, and Word documents.
|
||||||
|
*/
|
||||||
|
const ACCEPTED_TYPES = {
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
|
'image/jpeg': ['.jpg', '.jpeg'],
|
||||||
|
'image/png': ['.png'],
|
||||||
|
'image/webp': ['.webp'],
|
||||||
|
'video/mp4': ['.mp4'],
|
||||||
|
'video/webm': ['.webm'],
|
||||||
|
'application/msword': ['.doc'],
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HeroUploadZone() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [matchedTools, setMatchedTools] = useState<ToolOption[]>([]);
|
||||||
|
const [fileTypeLabel, setFileTypeLabel] = useState('');
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(acceptedFiles: File[]) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (acceptedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const file = acceptedFiles[0];
|
||||||
|
const tools = getToolsForFile(file);
|
||||||
|
|
||||||
|
if (tools.length === 0) {
|
||||||
|
setError(t('home.unsupportedFile'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = detectFileCategory(file);
|
||||||
|
const label = getCategoryLabel(category);
|
||||||
|
|
||||||
|
setSelectedFile(file);
|
||||||
|
setMatchedTools(tools);
|
||||||
|
setFileTypeLabel(label);
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: ACCEPTED_TYPES,
|
||||||
|
maxFiles: 1,
|
||||||
|
maxSize: 100 * 1024 * 1024, // 100 MB (matches nginx config)
|
||||||
|
onDropRejected: (rejections) => {
|
||||||
|
const rejection = rejections[0];
|
||||||
|
if (rejection?.errors[0]?.code === 'file-too-large') {
|
||||||
|
setError(t('common.maxSize', { size: 100 }));
|
||||||
|
} else {
|
||||||
|
setError(t('home.unsupportedFile'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCloseModal = useCallback(() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setMatchedTools([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto mt-8 max-w-2xl">
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={`hero-upload-zone ${isDragActive ? 'drag-active' : ''}`}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={`mb-4 flex h-16 w-16 items-center justify-center rounded-2xl transition-colors ${
|
||||||
|
isDragActive
|
||||||
|
? 'bg-primary-100 dark:bg-primary-900/30'
|
||||||
|
: 'bg-primary-50 dark:bg-primary-900/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
className={`h-8 w-8 transition-colors ${
|
||||||
|
isDragActive
|
||||||
|
? 'text-primary-600 dark:text-primary-400'
|
||||||
|
: 'text-primary-500 dark:text-primary-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Text */}
|
||||||
|
<p className="mb-1 text-lg font-semibold text-slate-800 dark:text-slate-200">
|
||||||
|
{t('home.uploadCta')}
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t('home.uploadOr')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Supported formats */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
{['PDF', 'Word', 'JPG', 'PNG', 'WebP', 'MP4'].map((format) => (
|
||||||
|
<span
|
||||||
|
key={format}
|
||||||
|
className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{format}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File size hint */}
|
||||||
|
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{t('home.uploadSubtitle')}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Selector Modal */}
|
||||||
|
<ToolSelectorModal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
file={selectedFile}
|
||||||
|
tools={matchedTools}
|
||||||
|
fileTypeLabel={fileTypeLabel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/components/shared/ToolSelectorModal.tsx
Normal file
150
frontend/src/components/shared/ToolSelectorModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { X, File as FileIcon } from 'lucide-react';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
import { formatFileSize } from '@/utils/textTools';
|
||||||
|
import type { ToolOption } from '@/utils/fileRouting';
|
||||||
|
|
||||||
|
interface ToolSelectorModalProps {
|
||||||
|
/** Whether the modal is open */
|
||||||
|
isOpen: boolean;
|
||||||
|
/** Callback to close the modal */
|
||||||
|
onClose: () => void;
|
||||||
|
/** The uploaded file */
|
||||||
|
file: File | null;
|
||||||
|
/** Available tools for this file type */
|
||||||
|
tools: ToolOption[];
|
||||||
|
/** Detected file type label (e.g. "PDF", "Image") */
|
||||||
|
fileTypeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToolSelectorModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
file,
|
||||||
|
tools,
|
||||||
|
fileTypeLabel,
|
||||||
|
}: ToolSelectorModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setStoreFile = useFileStore((s) => s.setFile);
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleToolSelect = useCallback(
|
||||||
|
(tool: ToolOption) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Store file in zustand for the target tool to pick up
|
||||||
|
setStoreFile(file);
|
||||||
|
|
||||||
|
// Navigate to the tool page
|
||||||
|
navigate(tool.path);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[file, setStoreFile, navigate, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
const handleBackdropClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOpen || !file) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="tool-selector-title"
|
||||||
|
>
|
||||||
|
<div className="modal-content w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-5 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
id="tool-selector-title"
|
||||||
|
className="text-lg font-bold text-slate-900 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
{t('home.selectTool')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t('home.fileDetected', { type: fileTypeLabel })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Info */}
|
||||||
|
<div className="mb-5 flex items-center gap-3 rounded-xl bg-primary-50 p-3 ring-1 ring-primary-200 dark:bg-primary-900/20 dark:ring-primary-800">
|
||||||
|
<FileIcon className="h-8 w-8 flex-shrink-0 text-primary-600 dark:text-primary-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const Icon = tool.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tool.key}
|
||||||
|
onClick={() => handleToolSelect(tool)}
|
||||||
|
className="group flex flex-col items-center gap-2 rounded-xl p-4 ring-1 ring-slate-200 transition-all hover:ring-primary-300 hover:shadow-md dark:ring-slate-700 dark:hover:ring-primary-600"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-xl ${tool.bgColor}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-5 w-5 ${tool.iconColor}`} />
|
||||||
|
</div>
|
||||||
|
<span className="text-center text-xs font-medium text-slate-700 group-hover:text-primary-600 dark:text-slate-300 dark:group-hover:text-primary-400">
|
||||||
|
{t(`tools.${tool.key}.shortDesc`)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { ListOrdered } from 'lucide-react';
|
import { ListOrdered } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
type Position = 'bottom-center' | 'bottom-right' | 'bottom-left' | 'top-center' | 'top-right' | 'top-left';
|
type Position = 'bottom-center' | 'bottom-right' | 'bottom-left' | 'top-center' | 'top-right' | 'top-left';
|
||||||
|
|
||||||
@@ -40,6 +41,16 @@ export default function AddPageNumbers() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { ImageIcon } from 'lucide-react';
|
import { ImageIcon } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
type OutputFormat = 'jpg' | 'png' | 'webp';
|
type OutputFormat = 'jpg' | 'png' | 'webp';
|
||||||
|
|
||||||
@@ -40,6 +41,16 @@ export default function ImageConverter() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { FileImage } from 'lucide-react';
|
import { FileImage } from 'lucide-react';
|
||||||
@@ -7,6 +7,7 @@ import ProgressBar from '@/components/shared/ProgressBar';
|
|||||||
import DownloadButton from '@/components/shared/DownloadButton';
|
import DownloadButton from '@/components/shared/DownloadButton';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
export default function ImagesToPdf() {
|
export default function ImagesToPdf() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -22,6 +23,16 @@ export default function ImagesToPdf() {
|
|||||||
onError: () => 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) {
|
||||||
|
setFiles((prev) => [...prev, storeFile]);
|
||||||
|
clearStoreFile();
|
||||||
|
}
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/bmp'];
|
const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/bmp'];
|
||||||
|
|
||||||
const handleFilesSelect = (newFiles: FileList | File[]) => {
|
const handleFilesSelect = (newFiles: FileList | File[]) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { Layers } from 'lucide-react';
|
import { Layers } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { uploadFile, type TaskResponse } from '@/services/api';
|
import { uploadFile, type TaskResponse } from '@/services/api';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
export default function MergePdf() {
|
export default function MergePdf() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -25,6 +26,16 @@ export default function MergePdf() {
|
|||||||
onError: () => 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) {
|
||||||
|
setFiles((prev) => [...prev, storeFile]);
|
||||||
|
clearStoreFile();
|
||||||
|
}
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleFilesSelect = (newFiles: FileList | File[]) => {
|
const handleFilesSelect = (newFiles: FileList | File[]) => {
|
||||||
const fileArray = Array.from(newFiles).filter(
|
const fileArray = Array.from(newFiles).filter(
|
||||||
(f) => f.type === 'application/pdf'
|
(f) => f.type === 'application/pdf'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { Minimize2 } from 'lucide-react';
|
import { Minimize2 } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
type Quality = 'low' | 'medium' | 'high';
|
type Quality = 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
@@ -39,6 +40,16 @@ export default function PdfCompressor() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { ImageIcon } from 'lucide-react';
|
import { ImageIcon } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
type OutputFormat = 'png' | 'jpg';
|
type OutputFormat = 'png' | 'jpg';
|
||||||
|
|
||||||
@@ -40,6 +41,16 @@ export default function PdfToImages() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
export default function PdfToWord() {
|
export default function PdfToWord() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -35,6 +36,16 @@ export default function PdfToWord() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { Lock } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
export default function ProtectPdf() {
|
export default function ProtectPdf() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -40,6 +41,16 @@ export default function ProtectPdf() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
if (!passwordsMatch) return;
|
if (!passwordsMatch) return;
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { RotateCw } from 'lucide-react';
|
import { RotateCw } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
type Rotation = 90 | 180 | 270;
|
type Rotation = 90 | 180 | 270;
|
||||||
|
|
||||||
@@ -39,6 +40,16 @@ export default function RotatePdf() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { Scissors } from 'lucide-react';
|
import { Scissors } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
type SplitMode = 'all' | 'range';
|
type SplitMode = 'all' | 'range';
|
||||||
|
|
||||||
@@ -40,6 +41,16 @@ export default function SplitPdf() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
if (mode === 'range' && !pages.trim()) return;
|
if (mode === 'range' && !pages.trim()) return;
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { Unlock } from 'lucide-react';
|
import { Unlock } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
export default function UnlockPdf() {
|
export default function UnlockPdf() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -37,6 +38,16 @@ export default function UnlockPdf() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
if (!password) return;
|
if (!password) return;
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { Film } from 'lucide-react';
|
import { Film } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
export default function VideoToGif() {
|
export default function VideoToGif() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -45,6 +46,16 @@ export default function VideoToGif() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { Droplets } from 'lucide-react';
|
import { Droplets } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
export default function WatermarkPdf() {
|
export default function WatermarkPdf() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -38,6 +39,16 @@ export default function WatermarkPdf() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { FileOutput } from 'lucide-react';
|
import { FileOutput } from 'lucide-react';
|
||||||
@@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
|||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||||
import { generateToolSchema } from '@/utils/seo';
|
import { generateToolSchema } from '@/utils/seo';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
|
||||||
export default function WordToPdf() {
|
export default function WordToPdf() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -35,6 +36,16 @@ export default function WordToPdf() {
|
|||||||
onError: () => 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 handleUpload = async () => {
|
||||||
const id = await startUpload();
|
const id = await startUpload();
|
||||||
if (id) setPhase('processing');
|
if (id) setPhase('processing');
|
||||||
|
|||||||
@@ -28,7 +28,18 @@
|
|||||||
"pdfTools": "أدوات PDF",
|
"pdfTools": "أدوات PDF",
|
||||||
"imageTools": "أدوات الصور",
|
"imageTools": "أدوات الصور",
|
||||||
"videoTools": "أدوات الفيديو",
|
"videoTools": "أدوات الفيديو",
|
||||||
"textTools": "أدوات النصوص"
|
"textTools": "أدوات النصوص",
|
||||||
|
"uploadCta": "ارفع ملفك",
|
||||||
|
"uploadOr": "أو اسحب وأفلت ملفك هنا",
|
||||||
|
"uploadSubtitle": "نكتشف نوع ملفك تلقائياً ونعرض الأدوات المناسبة",
|
||||||
|
"selectTool": "اختر أداة",
|
||||||
|
"fileDetected": "اكتشفنا ملف {{type}}",
|
||||||
|
"unsupportedFile": "نوع الملف غير مدعوم. جرب PDF أو Word أو صور أو فيديو.",
|
||||||
|
"fileTypes": {
|
||||||
|
"image": "صورة",
|
||||||
|
"video": "فيديو",
|
||||||
|
"unknown": "غير معروف"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"pdfToWord": {
|
"pdfToWord": {
|
||||||
|
|||||||
@@ -28,7 +28,18 @@
|
|||||||
"pdfTools": "PDF Tools",
|
"pdfTools": "PDF Tools",
|
||||||
"imageTools": "Image Tools",
|
"imageTools": "Image Tools",
|
||||||
"videoTools": "Video Tools",
|
"videoTools": "Video Tools",
|
||||||
"textTools": "Text 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",
|
||||||
|
"selectTool": "Choose a Tool",
|
||||||
|
"fileDetected": "We detected a {{type}} file",
|
||||||
|
"unsupportedFile": "This file type is not supported. Try PDF, Word, images, or video.",
|
||||||
|
"fileTypes": {
|
||||||
|
"image": "Image",
|
||||||
|
"video": "Video",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"pdfToWord": {
|
"pdfToWord": {
|
||||||
|
|||||||
@@ -28,7 +28,18 @@
|
|||||||
"pdfTools": "Outils PDF",
|
"pdfTools": "Outils PDF",
|
||||||
"imageTools": "Outils d'images",
|
"imageTools": "Outils d'images",
|
||||||
"videoTools": "Outils vidéo",
|
"videoTools": "Outils vidéo",
|
||||||
"textTools": "Outils de texte"
|
"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",
|
||||||
|
"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.",
|
||||||
|
"fileTypes": {
|
||||||
|
"image": "Image",
|
||||||
|
"video": "Vidéo",
|
||||||
|
"unknown": "Inconnu"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"pdfToWord": {
|
"pdfToWord": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ListOrdered,
|
ListOrdered,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ToolCard from '@/components/shared/ToolCard';
|
import ToolCard from '@/components/shared/ToolCard';
|
||||||
|
import HeroUploadZone from '@/components/shared/HeroUploadZone';
|
||||||
import AdSlot from '@/components/layout/AdSlot';
|
import AdSlot from '@/components/layout/AdSlot';
|
||||||
|
|
||||||
interface ToolInfo {
|
interface ToolInfo {
|
||||||
@@ -80,6 +81,9 @@ export default function HomePage() {
|
|||||||
<p className="mx-auto mt-4 max-w-xl text-lg text-slate-500 dark:text-slate-400">
|
<p className="mx-auto mt-4 max-w-xl text-lg text-slate-500 dark:text-slate-400">
|
||||||
{t('home.heroSub')}
|
{t('home.heroSub')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Smart Upload Zone */}
|
||||||
|
<HeroUploadZone />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Ad Slot */}
|
{/* Ad Slot */}
|
||||||
|
|||||||
23
frontend/src/stores/fileStore.ts
Normal file
23
frontend/src/stores/fileStore.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface FileStoreState {
|
||||||
|
/** File passed from the homepage smart upload zone */
|
||||||
|
file: File | null;
|
||||||
|
/** Store a file for cross-route transfer */
|
||||||
|
setFile: (file: File) => void;
|
||||||
|
/** Clear the stored file after consumption */
|
||||||
|
clearFile: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand store for transferring a File object between routes.
|
||||||
|
* Used by the homepage HeroUploadZone → tool page flow.
|
||||||
|
*
|
||||||
|
* File objects cannot be serialized through React Router state,
|
||||||
|
* so we use an in-memory store instead.
|
||||||
|
*/
|
||||||
|
export const useFileStore = create<FileStoreState>((set) => ({
|
||||||
|
file: null,
|
||||||
|
setFile: (file) => set({ file }),
|
||||||
|
clearFile: () => set({ file: null }),
|
||||||
|
}));
|
||||||
@@ -112,3 +112,45 @@
|
|||||||
.animate-in {
|
.animate-in {
|
||||||
animation: fadeSlideIn 0.15s ease-out;
|
animation: fadeSlideIn 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hero upload zone — larger variant for the homepage */
|
||||||
|
.hero-upload-zone {
|
||||||
|
@apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-gradient-to-b from-slate-50 to-white p-10 text-center transition-all duration-200 cursor-pointer sm:p-12 dark:border-slate-600 dark:from-slate-800/60 dark:to-slate-800/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload-zone:hover {
|
||||||
|
@apply border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg dark:border-primary-500 dark:from-primary-900/20 dark:to-slate-800/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload-zone.drag-active {
|
||||||
|
@apply border-primary-500 bg-gradient-to-b from-primary-100 to-primary-50 ring-2 ring-primary-300 shadow-xl dark:border-primary-400 dark:from-primary-900/30 dark:to-primary-900/10 dark:ring-primary-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal animations */
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
animation: modalFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
animation: modalSlideUp 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
120
frontend/src/utils/fileRouting.ts
Normal file
120
frontend/src/utils/fileRouting.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
FileOutput,
|
||||||
|
Minimize2,
|
||||||
|
Layers,
|
||||||
|
Scissors,
|
||||||
|
RotateCw,
|
||||||
|
Image,
|
||||||
|
FileImage,
|
||||||
|
Droplets,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
ListOrdered,
|
||||||
|
ImageIcon,
|
||||||
|
Film,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { ComponentType, SVGProps } from 'react';
|
||||||
|
|
||||||
|
export interface ToolOption {
|
||||||
|
/** i18n key inside tools.{key} */
|
||||||
|
key: string;
|
||||||
|
/** Route path */
|
||||||
|
path: string;
|
||||||
|
/** Lucide icon component */
|
||||||
|
icon: ComponentType<SVGProps<SVGSVGElement> & { className?: string }>;
|
||||||
|
/** Tailwind bg color class for the icon container */
|
||||||
|
bgColor: string;
|
||||||
|
/** Tailwind text color class for the icon */
|
||||||
|
iconColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF tools available when a .pdf file is uploaded */
|
||||||
|
const pdfTools: ToolOption[] = [
|
||||||
|
{ key: 'compressPdf', path: '/tools/compress-pdf', icon: Minimize2, bgColor: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
|
||||||
|
{ key: 'mergePdf', path: '/tools/merge-pdf', icon: Layers, bgColor: 'bg-violet-100 dark:bg-violet-900/30', iconColor: 'text-violet-600 dark:text-violet-400' },
|
||||||
|
{ key: 'splitPdf', path: '/tools/split-pdf', icon: Scissors, bgColor: 'bg-pink-100 dark:bg-pink-900/30', iconColor: 'text-pink-600 dark:text-pink-400' },
|
||||||
|
{ key: 'rotatePdf', path: '/tools/rotate-pdf', icon: RotateCw, bgColor: 'bg-teal-100 dark:bg-teal-900/30', iconColor: 'text-teal-600 dark:text-teal-400' },
|
||||||
|
{ key: 'pdfToWord', path: '/tools/pdf-to-word', icon: FileText, bgColor: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
|
||||||
|
{ key: 'pdfToImages', path: '/tools/pdf-to-images', icon: Image, bgColor: 'bg-amber-100 dark:bg-amber-900/30', iconColor: 'text-amber-600 dark:text-amber-400' },
|
||||||
|
{ key: 'watermarkPdf', path: '/tools/watermark-pdf', icon: Droplets, bgColor: 'bg-cyan-100 dark:bg-cyan-900/30', iconColor: 'text-cyan-600 dark:text-cyan-400' },
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Image tools available when an image is uploaded */
|
||||||
|
const imageTools: ToolOption[] = [
|
||||||
|
{ key: 'imageConvert', path: '/tools/image-converter', icon: ImageIcon, bgColor: 'bg-purple-100 dark:bg-purple-900/30', iconColor: 'text-purple-600 dark:text-purple-400' },
|
||||||
|
{ key: 'imagesToPdf', path: '/tools/images-to-pdf', icon: FileImage, bgColor: 'bg-lime-100 dark:bg-lime-900/30', iconColor: 'text-lime-600 dark:text-lime-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Video tools available when a video is uploaded */
|
||||||
|
const videoTools: ToolOption[] = [
|
||||||
|
{ key: 'videoToGif', path: '/tools/video-to-gif', icon: Film, bgColor: 'bg-emerald-100 dark:bg-emerald-900/30', iconColor: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Word document tools */
|
||||||
|
const wordTools: ToolOption[] = [
|
||||||
|
{ key: 'wordToPdf', path: '/tools/word-to-pdf', icon: FileOutput, bgColor: 'bg-blue-100 dark:bg-blue-900/30', iconColor: 'text-blue-600 dark:text-blue-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** File type category labels for i18n */
|
||||||
|
export type FileCategory = 'pdf' | 'image' | 'video' | 'word' | 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the category of a file based on its extension and MIME type.
|
||||||
|
*/
|
||||||
|
export function detectFileCategory(file: File): FileCategory {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const mime = file.type.toLowerCase();
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
if (ext === 'pdf' || mime === 'application/pdf') return 'pdf';
|
||||||
|
|
||||||
|
// Images
|
||||||
|
if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg'].includes(ext) || mime.startsWith('image/')) return 'image';
|
||||||
|
|
||||||
|
// Video
|
||||||
|
if (['mp4', 'webm', 'avi', 'mov', 'mkv'].includes(ext) || mime.startsWith('video/')) return 'video';
|
||||||
|
|
||||||
|
// Word documents
|
||||||
|
if (['doc', 'docx'].includes(ext) || mime.includes('word') || mime.includes('document')) return 'word';
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of available tools for a given file.
|
||||||
|
* Returns an empty array if the file type is unsupported.
|
||||||
|
*/
|
||||||
|
export function getToolsForFile(file: File): ToolOption[] {
|
||||||
|
const category = detectFileCategory(file);
|
||||||
|
|
||||||
|
switch (category) {
|
||||||
|
case 'pdf':
|
||||||
|
return pdfTools;
|
||||||
|
case 'image':
|
||||||
|
return imageTools;
|
||||||
|
case 'video':
|
||||||
|
return videoTools;
|
||||||
|
case 'word':
|
||||||
|
return wordTools;
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map file category to i18n label key (home.fileType.*)
|
||||||
|
*/
|
||||||
|
export function getCategoryLabel(category: FileCategory): string {
|
||||||
|
const labels: Record<FileCategory, string> = {
|
||||||
|
pdf: 'PDF',
|
||||||
|
image: 'home.fileTypes.image',
|
||||||
|
video: 'home.fileTypes.video',
|
||||||
|
word: 'Word',
|
||||||
|
unknown: 'home.fileTypes.unknown',
|
||||||
|
};
|
||||||
|
return labels[category];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user