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 { 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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
<p className="mx-auto mt-4 max-w-xl text-lg text-slate-500 dark:text-slate-400">
|
||||
{t('home.heroSub')}
|
||||
</p>
|
||||
|
||||
{/* Smart Upload Zone */}
|
||||
<HeroUploadZone />
|
||||
</section>
|
||||
|
||||
{/* 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 {
|
||||
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