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:
Your Name
2026-03-04 00:59:11 +02:00
parent aa80980a29
commit 2e97741d60
23 changed files with 692 additions and 17 deletions

View 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}
/>
</>
);
}

View 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>
);
}

View 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 { 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');

View 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 { 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');

View 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 { 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[]) => {

View 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'

View 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 { 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');

View 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 { 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');

View 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 { 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');

View 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 { 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();

View 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 { 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');

View 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 { 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();

View 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 { 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();

View 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 { 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');

View 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 { 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');

View 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 { 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');

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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 */}

View 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 }),
}));

View File

@@ -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;
}

View 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];
}