From 2e97741d60ae91a85beecfb2ad0206b4a07f55c3 Mon Sep 17 00:00:00 2001 From: Your Name <119736744+aborayan2022@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:59:11 +0200 Subject: [PATCH] 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. --- .../src/components/shared/HeroUploadZone.tsx | 149 +++++++++++++++++ .../components/shared/ToolSelectorModal.tsx | 150 ++++++++++++++++++ .../src/components/tools/AddPageNumbers.tsx | 13 +- .../src/components/tools/ImageConverter.tsx | 13 +- frontend/src/components/tools/ImagesToPdf.tsx | 13 +- frontend/src/components/tools/MergePdf.tsx | 13 +- .../src/components/tools/PdfCompressor.tsx | 13 +- frontend/src/components/tools/PdfToImages.tsx | 13 +- frontend/src/components/tools/PdfToWord.tsx | 13 +- frontend/src/components/tools/ProtectPdf.tsx | 13 +- frontend/src/components/tools/RotatePdf.tsx | 13 +- frontend/src/components/tools/SplitPdf.tsx | 13 +- frontend/src/components/tools/UnlockPdf.tsx | 13 +- frontend/src/components/tools/VideoToGif.tsx | 13 +- .../src/components/tools/WatermarkPdf.tsx | 13 +- frontend/src/components/tools/WordToPdf.tsx | 13 +- frontend/src/i18n/ar.json | 13 +- frontend/src/i18n/en.json | 13 +- frontend/src/i18n/fr.json | 13 +- frontend/src/pages/HomePage.tsx | 4 + frontend/src/stores/fileStore.ts | 23 +++ frontend/src/styles/global.css | 42 +++++ frontend/src/utils/fileRouting.ts | 120 ++++++++++++++ 23 files changed, 692 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/shared/HeroUploadZone.tsx create mode 100644 frontend/src/components/shared/ToolSelectorModal.tsx create mode 100644 frontend/src/stores/fileStore.ts create mode 100644 frontend/src/utils/fileRouting.ts diff --git a/frontend/src/components/shared/HeroUploadZone.tsx b/frontend/src/components/shared/HeroUploadZone.tsx new file mode 100644 index 0000000..bcbfbb0 --- /dev/null +++ b/frontend/src/components/shared/HeroUploadZone.tsx @@ -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(null); + const [matchedTools, setMatchedTools] = useState([]); + const [fileTypeLabel, setFileTypeLabel] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + const [error, setError] = useState(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 ( + <> +
+
+ + + {/* Icon */} +
+ +
+ + {/* CTA Text */} +

+ {t('home.uploadCta')} +

+

+ {t('home.uploadOr')} +

+ + {/* Supported formats */} +
+ {['PDF', 'Word', 'JPG', 'PNG', 'WebP', 'MP4'].map((format) => ( + + {format} + + ))} +
+ + {/* File size hint */} +

+ + {t('home.uploadSubtitle')} +

+
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} +
+ + {/* Tool Selector Modal */} + + + ); +} diff --git a/frontend/src/components/shared/ToolSelectorModal.tsx b/frontend/src/components/shared/ToolSelectorModal.tsx new file mode 100644 index 0000000..3e4049a --- /dev/null +++ b/frontend/src/components/shared/ToolSelectorModal.tsx @@ -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) => { + if (e.target === e.currentTarget) onClose(); + }, + [onClose] + ); + + if (!isOpen || !file) return null; + + return ( +
+
+ {/* Header */} +
+
+

+ {t('home.selectTool')} +

+

+ {t('home.fileDetected', { type: fileTypeLabel })} +

+
+ +
+ + {/* File Info */} +
+ +
+

+ {file.name} +

+

+ {formatFileSize(file.size)} +

+
+
+ + {/* Tools Grid */} +
+ {tools.map((tool) => { + const Icon = tool.icon; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/tools/AddPageNumbers.tsx b/frontend/src/components/tools/AddPageNumbers.tsx index c325378..91433a8 100644 --- a/frontend/src/components/tools/AddPageNumbers.tsx +++ b/frontend/src/components/tools/AddPageNumbers.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { ListOrdered } from 'lucide-react'; @@ -9,6 +9,7 @@ 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'; 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'), }); + // 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'); diff --git a/frontend/src/components/tools/ImageConverter.tsx b/frontend/src/components/tools/ImageConverter.tsx index 6efbd4f..4396790 100644 --- a/frontend/src/components/tools/ImageConverter.tsx +++ b/frontend/src/components/tools/ImageConverter.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { ImageIcon } from 'lucide-react'; @@ -9,6 +9,7 @@ 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'; type OutputFormat = 'jpg' | 'png' | 'webp'; @@ -40,6 +41,16 @@ export default function ImageConverter() { 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'); diff --git a/frontend/src/components/tools/ImagesToPdf.tsx b/frontend/src/components/tools/ImagesToPdf.tsx index f947edc..875c85e 100644 --- a/frontend/src/components/tools/ImagesToPdf.tsx +++ b/frontend/src/components/tools/ImagesToPdf.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { FileImage } from 'lucide-react'; @@ -7,6 +7,7 @@ import ProgressBar from '@/components/shared/ProgressBar'; import DownloadButton from '@/components/shared/DownloadButton'; import { useTaskPolling } from '@/hooks/useTaskPolling'; import { generateToolSchema } from '@/utils/seo'; +import { useFileStore } from '@/stores/fileStore'; export default function ImagesToPdf() { const { t } = useTranslation(); @@ -22,6 +23,16 @@ export default function ImagesToPdf() { 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 handleFilesSelect = (newFiles: FileList | File[]) => { diff --git a/frontend/src/components/tools/MergePdf.tsx b/frontend/src/components/tools/MergePdf.tsx index 08ce98c..ed1e04c 100644 --- a/frontend/src/components/tools/MergePdf.tsx +++ b/frontend/src/components/tools/MergePdf.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { Layers } from 'lucide-react'; @@ -9,6 +9,7 @@ import AdSlot from '@/components/layout/AdSlot'; import { useTaskPolling } from '@/hooks/useTaskPolling'; import { uploadFile, type TaskResponse } from '@/services/api'; import { generateToolSchema } from '@/utils/seo'; +import { useFileStore } from '@/stores/fileStore'; export default function MergePdf() { const { t } = useTranslation(); @@ -25,6 +26,16 @@ export default function MergePdf() { 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 fileArray = Array.from(newFiles).filter( (f) => f.type === 'application/pdf' diff --git a/frontend/src/components/tools/PdfCompressor.tsx b/frontend/src/components/tools/PdfCompressor.tsx index 5d5f421..19e3d4e 100644 --- a/frontend/src/components/tools/PdfCompressor.tsx +++ b/frontend/src/components/tools/PdfCompressor.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { Minimize2 } from 'lucide-react'; @@ -9,6 +9,7 @@ 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'; type Quality = 'low' | 'medium' | 'high'; @@ -39,6 +40,16 @@ export default function PdfCompressor() { 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'); diff --git a/frontend/src/components/tools/PdfToImages.tsx b/frontend/src/components/tools/PdfToImages.tsx index 757b368..410139c 100644 --- a/frontend/src/components/tools/PdfToImages.tsx +++ b/frontend/src/components/tools/PdfToImages.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { ImageIcon } from 'lucide-react'; @@ -9,6 +9,7 @@ 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'; type OutputFormat = 'png' | 'jpg'; @@ -40,6 +41,16 @@ export default function PdfToImages() { 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'); diff --git a/frontend/src/components/tools/PdfToWord.tsx b/frontend/src/components/tools/PdfToWord.tsx index d8c4f43..14730ce 100644 --- a/frontend/src/components/tools/PdfToWord.tsx +++ b/frontend/src/components/tools/PdfToWord.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { FileText } from 'lucide-react'; @@ -9,6 +9,7 @@ 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 PdfToWord() { const { t } = useTranslation(); @@ -35,6 +36,16 @@ export default function PdfToWord() { 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'); diff --git a/frontend/src/components/tools/ProtectPdf.tsx b/frontend/src/components/tools/ProtectPdf.tsx index a9c8a44..a269536 100644 --- a/frontend/src/components/tools/ProtectPdf.tsx +++ b/frontend/src/components/tools/ProtectPdf.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { Lock } from 'lucide-react'; @@ -9,6 +9,7 @@ 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 ProtectPdf() { const { t } = useTranslation(); @@ -40,6 +41,16 @@ export default function ProtectPdf() { 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 () => { if (!passwordsMatch) return; const id = await startUpload(); diff --git a/frontend/src/components/tools/RotatePdf.tsx b/frontend/src/components/tools/RotatePdf.tsx index 04a12bb..7b196a1 100644 --- a/frontend/src/components/tools/RotatePdf.tsx +++ b/frontend/src/components/tools/RotatePdf.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { RotateCw } from 'lucide-react'; @@ -9,6 +9,7 @@ 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'; type Rotation = 90 | 180 | 270; @@ -39,6 +40,16 @@ export default function RotatePdf() { 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'); diff --git a/frontend/src/components/tools/SplitPdf.tsx b/frontend/src/components/tools/SplitPdf.tsx index b2385ec..a1b186f 100644 --- a/frontend/src/components/tools/SplitPdf.tsx +++ b/frontend/src/components/tools/SplitPdf.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { Scissors } from 'lucide-react'; @@ -9,6 +9,7 @@ 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'; type SplitMode = 'all' | 'range'; @@ -40,6 +41,16 @@ export default function SplitPdf() { 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 () => { if (mode === 'range' && !pages.trim()) return; const id = await startUpload(); diff --git a/frontend/src/components/tools/UnlockPdf.tsx b/frontend/src/components/tools/UnlockPdf.tsx index 3afd6cf..f399682 100644 --- a/frontend/src/components/tools/UnlockPdf.tsx +++ b/frontend/src/components/tools/UnlockPdf.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { Unlock } from 'lucide-react'; @@ -9,6 +9,7 @@ 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 UnlockPdf() { const { t } = useTranslation(); @@ -37,6 +38,16 @@ export default function UnlockPdf() { 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 () => { if (!password) return; const id = await startUpload(); diff --git a/frontend/src/components/tools/VideoToGif.tsx b/frontend/src/components/tools/VideoToGif.tsx index 660895d..43f1ec7 100644 --- a/frontend/src/components/tools/VideoToGif.tsx +++ b/frontend/src/components/tools/VideoToGif.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { Film } from 'lucide-react'; @@ -9,6 +9,7 @@ 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 VideoToGif() { const { t } = useTranslation(); @@ -45,6 +46,16 @@ export default function VideoToGif() { 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'); diff --git a/frontend/src/components/tools/WatermarkPdf.tsx b/frontend/src/components/tools/WatermarkPdf.tsx index d9536b2..898382c 100644 --- a/frontend/src/components/tools/WatermarkPdf.tsx +++ b/frontend/src/components/tools/WatermarkPdf.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { Droplets } from 'lucide-react'; @@ -9,6 +9,7 @@ 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 WatermarkPdf() { const { t } = useTranslation(); @@ -38,6 +39,16 @@ export default function WatermarkPdf() { 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'); diff --git a/frontend/src/components/tools/WordToPdf.tsx b/frontend/src/components/tools/WordToPdf.tsx index 05190d7..010ff53 100644 --- a/frontend/src/components/tools/WordToPdf.tsx +++ b/frontend/src/components/tools/WordToPdf.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { FileOutput } from 'lucide-react'; @@ -9,6 +9,7 @@ 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 WordToPdf() { const { t } = useTranslation(); @@ -35,6 +36,16 @@ export default function WordToPdf() { 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'); diff --git a/frontend/src/i18n/ar.json b/frontend/src/i18n/ar.json index 38f4131..1cb3750 100644 --- a/frontend/src/i18n/ar.json +++ b/frontend/src/i18n/ar.json @@ -28,7 +28,18 @@ "pdfTools": "أدوات PDF", "imageTools": "أدوات الصور", "videoTools": "أدوات الفيديو", - "textTools": "أدوات النصوص" + "textTools": "أدوات النصوص", + "uploadCta": "ارفع ملفك", + "uploadOr": "أو اسحب وأفلت ملفك هنا", + "uploadSubtitle": "نكتشف نوع ملفك تلقائياً ونعرض الأدوات المناسبة", + "selectTool": "اختر أداة", + "fileDetected": "اكتشفنا ملف {{type}}", + "unsupportedFile": "نوع الملف غير مدعوم. جرب PDF أو Word أو صور أو فيديو.", + "fileTypes": { + "image": "صورة", + "video": "فيديو", + "unknown": "غير معروف" + } }, "tools": { "pdfToWord": { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 84a1e3f..e852e35 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -28,7 +28,18 @@ "pdfTools": "PDF Tools", "imageTools": "Image 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": { "pdfToWord": { diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json index 8ee2fd3..a80c6c8 100644 --- a/frontend/src/i18n/fr.json +++ b/frontend/src/i18n/fr.json @@ -28,7 +28,18 @@ "pdfTools": "Outils PDF", "imageTools": "Outils d'images", "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": { "pdfToWord": { diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index c5a7256..67ad530 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -19,6 +19,7 @@ import { ListOrdered, } from 'lucide-react'; import ToolCard from '@/components/shared/ToolCard'; +import HeroUploadZone from '@/components/shared/HeroUploadZone'; import AdSlot from '@/components/layout/AdSlot'; interface ToolInfo { @@ -80,6 +81,9 @@ export default function HomePage() {

{t('home.heroSub')}

+ + {/* Smart Upload Zone */} + {/* Ad Slot */} diff --git a/frontend/src/stores/fileStore.ts b/frontend/src/stores/fileStore.ts new file mode 100644 index 0000000..e77e746 --- /dev/null +++ b/frontend/src/stores/fileStore.ts @@ -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((set) => ({ + file: null, + setFile: (file) => set({ file }), + clearFile: () => set({ file: null }), +})); diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index db36b67..c46366e 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -112,3 +112,45 @@ .animate-in { 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; +} diff --git a/frontend/src/utils/fileRouting.ts b/frontend/src/utils/fileRouting.ts new file mode 100644 index 0000000..32dabd1 --- /dev/null +++ b/frontend/src/utils/fileRouting.ts @@ -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 & { 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 = { + pdf: 'PDF', + image: 'home.fileTypes.image', + video: 'home.fileTypes.video', + word: 'Word', + unknown: 'home.fileTypes.unknown', + }; + return labels[category]; +}