feat: Initialize frontend with React, Vite, and Tailwind CSS
- Set up main entry point for React application. - Create About, Home, NotFound, Privacy, and Terms pages with SEO support. - Implement API service for file uploads and task management. - Add global styles using Tailwind CSS. - Create utility functions for SEO and text processing. - Configure Vite for development and production builds. - Set up Nginx configuration for serving frontend and backend. - Add scripts for cleanup of expired files and sitemap generation. - Implement deployment script for production environment.
This commit is contained in:
71
frontend/src/App.tsx
Normal file
71
frontend/src/App.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import { useDirection } from '@/hooks/useDirection';
|
||||
|
||||
// Pages
|
||||
const HomePage = lazy(() => import('@/pages/HomePage'));
|
||||
const AboutPage = lazy(() => import('@/pages/AboutPage'));
|
||||
const PrivacyPage = lazy(() => import('@/pages/PrivacyPage'));
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'));
|
||||
const TermsPage = lazy(() => import('@/pages/TermsPage'));
|
||||
|
||||
// Tool Pages
|
||||
const PdfToWord = lazy(() => import('@/components/tools/PdfToWord'));
|
||||
const WordToPdf = lazy(() => import('@/components/tools/WordToPdf'));
|
||||
const PdfCompressor = lazy(() => import('@/components/tools/PdfCompressor'));
|
||||
const ImageConverter = lazy(() => import('@/components/tools/ImageConverter'));
|
||||
const VideoToGif = lazy(() => import('@/components/tools/VideoToGif'));
|
||||
const WordCounter = lazy(() => import('@/components/tools/WordCounter'));
|
||||
const TextCleaner = lazy(() => import('@/components/tools/TextCleaner'));
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary-200 border-t-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useDirection();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto flex-1 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
{/* Pages */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
|
||||
{/* PDF Tools */}
|
||||
<Route path="/tools/pdf-to-word" element={<PdfToWord />} />
|
||||
<Route path="/tools/word-to-pdf" element={<WordToPdf />} />
|
||||
<Route path="/tools/compress-pdf" element={<PdfCompressor />} />
|
||||
|
||||
{/* Image Tools */}
|
||||
<Route path="/tools/image-converter" element={<ImageConverter />} />
|
||||
|
||||
{/* Video Tools */}
|
||||
<Route path="/tools/video-to-gif" element={<VideoToGif />} />
|
||||
|
||||
{/* Text Tools */}
|
||||
<Route path="/tools/word-counter" element={<WordCounter />} />
|
||||
<Route path="/tools/text-cleaner" element={<TextCleaner />} />
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/layout/AdSlot.tsx
Normal file
53
frontend/src/components/layout/AdSlot.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface AdSlotProps {
|
||||
/** AdSense ad slot ID */
|
||||
slot: string;
|
||||
/** Ad format: 'auto', 'rectangle', 'horizontal', 'vertical' */
|
||||
format?: string;
|
||||
/** Responsive mode */
|
||||
responsive?: boolean;
|
||||
/** Additional CSS class */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google AdSense ad slot component.
|
||||
* Loads the ad unit once and handles cleanup.
|
||||
*/
|
||||
export default function AdSlot({
|
||||
slot,
|
||||
format = 'auto',
|
||||
responsive = true,
|
||||
className = '',
|
||||
}: AdSlotProps) {
|
||||
const adRef = useRef<HTMLModElement>(null);
|
||||
const isLoaded = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded.current) return;
|
||||
|
||||
try {
|
||||
// Push ad to AdSense queue
|
||||
const adsbygoogle = (window as any).adsbygoogle || [];
|
||||
adsbygoogle.push({});
|
||||
isLoaded.current = true;
|
||||
} catch {
|
||||
// AdSense not loaded (e.g., ad blocker)
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`ad-slot ${className}`}>
|
||||
<ins
|
||||
ref={adRef}
|
||||
className="adsbygoogle"
|
||||
style={{ display: 'block' }}
|
||||
data-ad-client={import.meta.env.VITE_ADSENSE_CLIENT_ID || ''}
|
||||
data-ad-slot={slot}
|
||||
data-ad-format={format}
|
||||
data-full-width-responsive={responsive ? 'true' : 'false'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/layout/Footer.tsx
Normal file
45
frontend/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<footer className="border-t border-slate-200 bg-slate-50">
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
© {new Date().getFullYear()} {t('common.appName')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600"
|
||||
>
|
||||
{t('common.privacy')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600"
|
||||
>
|
||||
{t('common.terms')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600"
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/layout/Header.tsx
Normal file
50
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileText, Globe } from 'lucide-react';
|
||||
|
||||
export default function Header() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language === 'ar' ? 'en' : 'ar';
|
||||
i18n.changeLanguage(newLang);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-primary-600">
|
||||
<FileText className="h-7 w-7" />
|
||||
<span>{t('common.appName')}</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm font-medium text-slate-600 transition-colors hover:text-primary-600"
|
||||
>
|
||||
{t('common.home')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-sm font-medium text-slate-600 transition-colors hover:text-primary-600"
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Language Toggle */}
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100"
|
||||
aria-label={t('common.language')}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{i18n.language === 'ar' ? 'English' : 'العربية'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/shared/DownloadButton.tsx
Normal file
88
frontend/src/components/shared/DownloadButton.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, RotateCcw, Clock } from 'lucide-react';
|
||||
import type { TaskResult } from '@/services/api';
|
||||
import { formatFileSize } from '@/utils/textTools';
|
||||
|
||||
interface DownloadButtonProps {
|
||||
/** Task result containing download URL */
|
||||
result: TaskResult;
|
||||
/** Called when user wants to start over */
|
||||
onStartOver: () => void;
|
||||
}
|
||||
|
||||
export default function DownloadButton({ result, onStartOver }: DownloadButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!result.download_url) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-emerald-50 p-6 ring-1 ring-emerald-200">
|
||||
{/* Success header */}
|
||||
<div className="mb-4 text-center">
|
||||
<p className="text-lg font-semibold text-emerald-800">
|
||||
{t('result.conversionComplete')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-emerald-600">
|
||||
{t('result.downloadReady')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File stats */}
|
||||
{(result.original_size || result.compressed_size) && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{result.original_size && (
|
||||
<div className="rounded-lg bg-white p-3 text-center">
|
||||
<p className="text-xs text-slate-500">{t('result.originalSize')}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatFileSize(result.original_size)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{result.compressed_size && (
|
||||
<div className="rounded-lg bg-white p-3 text-center">
|
||||
<p className="text-xs text-slate-500">{t('result.newSize')}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatFileSize(result.compressed_size)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{result.reduction_percent !== undefined && (
|
||||
<div className="rounded-lg bg-white p-3 text-center">
|
||||
<p className="text-xs text-slate-500">{t('result.reduction')}</p>
|
||||
<p className="text-sm font-semibold text-emerald-600">
|
||||
{result.reduction_percent}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download button */}
|
||||
<a
|
||||
href={result.download_url}
|
||||
download={result.filename}
|
||||
className="btn-success w-full"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
{t('common.download')} — {result.filename}
|
||||
</a>
|
||||
|
||||
{/* Expiry notice */}
|
||||
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs text-slate-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{t('result.linkExpiry')}
|
||||
</div>
|
||||
|
||||
{/* Start over */}
|
||||
<button
|
||||
onClick={onStartOver}
|
||||
className="mt-4 flex w-full items-center justify-center gap-2 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/shared/FileUploader.tsx
Normal file
132
frontend/src/components/shared/FileUploader.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone, type Accept } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, File, X } from 'lucide-react';
|
||||
import { formatFileSize } from '@/utils/textTools';
|
||||
|
||||
interface FileUploaderProps {
|
||||
/** Called when a file is selected/dropped */
|
||||
onFileSelect: (file: File) => void;
|
||||
/** Currently selected file */
|
||||
file: File | null;
|
||||
/** Accepted MIME types */
|
||||
accept?: Accept;
|
||||
/** Maximum file size in MB */
|
||||
maxSizeMB?: number;
|
||||
/** Whether upload is in progress */
|
||||
isUploading?: boolean;
|
||||
/** Upload progress percentage */
|
||||
uploadProgress?: number;
|
||||
/** Error message */
|
||||
error?: string | null;
|
||||
/** Reset handler */
|
||||
onReset?: () => void;
|
||||
/** Descriptive text for accepted file types */
|
||||
acceptLabel?: string;
|
||||
}
|
||||
|
||||
export default function FileUploader({
|
||||
onFileSelect,
|
||||
file,
|
||||
accept,
|
||||
maxSizeMB = 20,
|
||||
isUploading = false,
|
||||
uploadProgress = 0,
|
||||
error,
|
||||
onReset,
|
||||
acceptLabel,
|
||||
}: FileUploaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
onFileSelect(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[onFileSelect]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
maxFiles: 1,
|
||||
maxSize: maxSizeMB * 1024 * 1024,
|
||||
disabled: isUploading,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Drop Zone */}
|
||||
{!file && (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`upload-zone ${isDragActive ? 'drag-active' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload
|
||||
className={`mb-4 h-12 w-12 ${
|
||||
isDragActive ? 'text-primary-500' : 'text-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<p className="mb-2 text-base font-medium text-slate-700">
|
||||
{t('common.dragDrop')}
|
||||
</p>
|
||||
{acceptLabel && (
|
||||
<p className="text-sm text-slate-500">{acceptLabel}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{t('common.maxSize', { size: maxSizeMB })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected File */}
|
||||
{file && !isUploading && (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-primary-50 p-4 ring-1 ring-primary-200">
|
||||
<File className="h-8 w-8 flex-shrink-0 text-primary-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
{onReset && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-600"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Progress */}
|
||||
{isUploading && (
|
||||
<div className="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t('common.upload')}...
|
||||
</span>
|
||||
<span className="text-sm text-slate-500">{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary-600 transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/shared/ProgressBar.tsx
Normal file
42
frontend/src/components/shared/ProgressBar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
/** Current task state */
|
||||
state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE' | string;
|
||||
/** Progress message */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar({ state, message }: ProgressBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isActive = state === 'PENDING' || state === 'PROCESSING';
|
||||
const isComplete = state === 'SUCCESS';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
{isActive && (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-600" />
|
||||
)}
|
||||
{isComplete && (
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{message || t('common.processing')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animated progress bar for active states */}
|
||||
{isActive && (
|
||||
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="progress-bar-animated h-full w-2/3 rounded-full bg-primary-500 transition-all" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/shared/ToolCard.tsx
Normal file
43
frontend/src/components/shared/ToolCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ToolCardProps {
|
||||
/** Tool route path */
|
||||
to: string;
|
||||
/** Tool title */
|
||||
title: string;
|
||||
/** Short description */
|
||||
description: string;
|
||||
/** Pre-rendered icon element */
|
||||
icon: ReactNode;
|
||||
/** Icon background color class */
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
export default function ToolCard({
|
||||
to,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
bgColor,
|
||||
}: ToolCardProps) {
|
||||
return (
|
||||
<Link to={to} className="tool-card group block">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${bgColor}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-slate-900 group-hover:text-primary-600 transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
176
frontend/src/components/tools/ImageConverter.tsx
Normal file
176
frontend/src/components/tools/ImageConverter.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
|
||||
type OutputFormat = 'jpg' | 'png' | 'webp';
|
||||
|
||||
export default function ImageConverter() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [format, setFormat] = useState<OutputFormat>('jpg');
|
||||
const [quality, setQuality] = useState(85);
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/image/convert',
|
||||
maxSizeMB: 10,
|
||||
acceptedTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
extraData: { format, quality: quality.toString() },
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const formats: { value: OutputFormat; label: string }[] = [
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'webp', label: 'WebP' },
|
||||
];
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.imageConvert.title'),
|
||||
description: t('tools.imageConvert.description'),
|
||||
url: `${window.location.origin}/tools/image-converter`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.imageConvert.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.imageConvert.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/image-converter`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-purple-100">
|
||||
<ImageIcon className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.imageConvert.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.imageConvert.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/webp': ['.webp'],
|
||||
}}
|
||||
maxSizeMB={10}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="Images (PNG, JPG, WebP)"
|
||||
/>
|
||||
|
||||
{file && !isUploading && (
|
||||
<>
|
||||
{/* Format Selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
Convert to:
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{formats.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFormat(f.value)}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
format === f.value
|
||||
? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold'
|
||||
: 'bg-white ring-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Slider (for lossy formats) */}
|
||||
{format !== 'png' && (
|
||||
<div>
|
||||
<label className="mb-2 flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>Quality</span>
|
||||
<span className="text-primary-600">{quality}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full accent-primary-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleUpload} className="btn-primary w-full">
|
||||
{t('tools.imageConvert.shortDesc')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && !result && (
|
||||
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{taskError}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
148
frontend/src/components/tools/PdfCompressor.tsx
Normal file
148
frontend/src/components/tools/PdfCompressor.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Minimize2 } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
|
||||
export default function PdfCompressor() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [quality, setQuality] = useState<Quality>('medium');
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/compress/pdf',
|
||||
maxSizeMB: 20,
|
||||
acceptedTypes: ['pdf'],
|
||||
extraData: { quality },
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const qualityOptions: { value: Quality; label: string; desc: string }[] = [
|
||||
{ value: 'low', label: t('tools.compressPdf.qualityLow'), desc: '72 DPI' },
|
||||
{ value: 'medium', label: t('tools.compressPdf.qualityMedium'), desc: '150 DPI' },
|
||||
{ value: 'high', label: t('tools.compressPdf.qualityHigh'), desc: '300 DPI' },
|
||||
];
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.compressPdf.title'),
|
||||
description: t('tools.compressPdf.description'),
|
||||
url: `${window.location.origin}/tools/compress-pdf`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.compressPdf.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.compressPdf.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/compress-pdf`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-orange-100">
|
||||
<Minimize2 className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.compressPdf.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.compressPdf.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{ 'application/pdf': ['.pdf'] }}
|
||||
maxSizeMB={20}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="PDF (.pdf)"
|
||||
/>
|
||||
|
||||
{/* Quality Selector */}
|
||||
{file && !isUploading && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{qualityOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setQuality(opt.value)}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
quality === opt.value
|
||||
? 'bg-primary-50 ring-primary-300 text-primary-700'
|
||||
: 'bg-white ring-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">{opt.label}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleUpload} className="btn-primary w-full">
|
||||
{t('tools.compressPdf.shortDesc')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && !result && (
|
||||
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{taskError}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
frontend/src/components/tools/PdfToWord.tsx
Normal file
128
frontend/src/components/tools/PdfToWord.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { FileText } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
|
||||
export default function PdfToWord() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/convert/pdf-to-word',
|
||||
maxSizeMB: 20,
|
||||
acceptedTypes: ['pdf'],
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.pdfToWord.title'),
|
||||
description: t('tools.pdfToWord.description'),
|
||||
url: `${window.location.origin}/tools/pdf-to-word`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.pdfToWord.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.pdfToWord.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/pdf-to-word`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Tool Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100">
|
||||
<FileText className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.pdfToWord.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.pdfToWord.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Ad Slot - Top */}
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{/* Upload Phase */}
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{ 'application/pdf': ['.pdf'] }}
|
||||
maxSizeMB={20}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="PDF (.pdf)"
|
||||
/>
|
||||
{file && !isUploading && (
|
||||
<button onClick={handleUpload} className="btn-primary w-full">
|
||||
{t('tools.pdfToWord.shortDesc')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing Phase */}
|
||||
{phase === 'processing' && !result && (
|
||||
<ProgressBar
|
||||
state={status?.state || 'PENDING'}
|
||||
message={status?.progress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Done Phase */}
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{(phase === 'done' && taskError) && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{taskError}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ad Slot - Bottom */}
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/tools/TextCleaner.tsx
Normal file
146
frontend/src/components/tools/TextCleaner.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Eraser, Copy, Check } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { removeExtraSpaces, convertCase, removeDiacritics } from '@/utils/textTools';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
|
||||
export default function TextCleaner() {
|
||||
const { t } = useTranslation();
|
||||
const [input, setInput] = useState('');
|
||||
const [output, setOutput] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const applyTransform = (type: string) => {
|
||||
let result = input;
|
||||
switch (type) {
|
||||
case 'removeSpaces':
|
||||
result = removeExtraSpaces(input);
|
||||
break;
|
||||
case 'upper':
|
||||
result = convertCase(input, 'upper');
|
||||
break;
|
||||
case 'lower':
|
||||
result = convertCase(input, 'lower');
|
||||
break;
|
||||
case 'title':
|
||||
result = convertCase(input, 'title');
|
||||
break;
|
||||
case 'sentence':
|
||||
result = convertCase(input, 'sentence');
|
||||
break;
|
||||
case 'removeDiacritics':
|
||||
result = removeDiacritics(input);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setOutput(result);
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(output || input);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{ key: 'removeSpaces', label: t('tools.textCleaner.removeSpaces'), color: 'bg-blue-600 hover:bg-blue-700' },
|
||||
{ key: 'upper', label: t('tools.textCleaner.toUpperCase'), color: 'bg-purple-600 hover:bg-purple-700' },
|
||||
{ key: 'lower', label: t('tools.textCleaner.toLowerCase'), color: 'bg-emerald-600 hover:bg-emerald-700' },
|
||||
{ key: 'title', label: t('tools.textCleaner.toTitleCase'), color: 'bg-orange-600 hover:bg-orange-700' },
|
||||
{ key: 'sentence', label: t('tools.textCleaner.toSentenceCase'), color: 'bg-rose-600 hover:bg-rose-700' },
|
||||
{ key: 'removeDiacritics', label: t('tools.textCleaner.removeDiacritics'), color: 'bg-amber-600 hover:bg-amber-700' },
|
||||
];
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.textCleaner.title'),
|
||||
description: t('tools.textCleaner.description'),
|
||||
url: `${window.location.origin}/tools/text-cleaner`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.textCleaner.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.textCleaner.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/text-cleaner`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-100">
|
||||
<Eraser className="h-8 w-8 text-indigo-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.textCleaner.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.textCleaner.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{/* Input */}
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setCopied(false);
|
||||
}}
|
||||
placeholder={t('tools.wordCounter.placeholder')}
|
||||
className="input-field mb-4 min-h-[150px] resize-y text-sm"
|
||||
dir="auto"
|
||||
/>
|
||||
|
||||
{/* Transform Buttons */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{buttons.map((btn) => (
|
||||
<button
|
||||
key={btn.key}
|
||||
onClick={() => applyTransform(btn.key)}
|
||||
disabled={!input.trim()}
|
||||
className={`rounded-lg px-4 py-2 text-xs font-medium text-white transition-colors disabled:opacity-40 ${btn.color}`}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{output && (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={output}
|
||||
readOnly
|
||||
className="input-field min-h-[150px] resize-y bg-emerald-50 text-sm"
|
||||
dir="auto"
|
||||
/>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="absolute right-3 top-3 flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm ring-1 ring-slate-200 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-emerald-600" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t('tools.textCleaner.copyResult')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
192
frontend/src/components/tools/VideoToGif.tsx
Normal file
192
frontend/src/components/tools/VideoToGif.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Film } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
|
||||
export default function VideoToGif() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [startTime, setStartTime] = useState(0);
|
||||
const [duration, setDuration] = useState(5);
|
||||
const [fps, setFps] = useState(10);
|
||||
const [width, setWidth] = useState(480);
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/video/to-gif',
|
||||
maxSizeMB: 50,
|
||||
acceptedTypes: ['mp4', 'webm'],
|
||||
extraData: {
|
||||
start_time: startTime.toString(),
|
||||
duration: duration.toString(),
|
||||
fps: fps.toString(),
|
||||
width: width.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.videoToGif.title'),
|
||||
description: t('tools.videoToGif.description'),
|
||||
url: `${window.location.origin}/tools/video-to-gif`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.videoToGif.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.videoToGif.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/video-to-gif`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-100">
|
||||
<Film className="h-8 w-8 text-emerald-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.videoToGif.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.videoToGif.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{
|
||||
'video/mp4': ['.mp4'],
|
||||
'video/webm': ['.webm'],
|
||||
}}
|
||||
maxSizeMB={50}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="Video (MP4, WebM) — max 50MB"
|
||||
/>
|
||||
|
||||
{file && !isUploading && (
|
||||
<>
|
||||
{/* GIF Options */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">
|
||||
{t('tools.videoToGif.startTime')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(Number(e.target.value))}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">
|
||||
{t('tools.videoToGif.duration')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="15"
|
||||
step="0.5"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Number(e.target.value))}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">
|
||||
{t('tools.videoToGif.fps')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={fps}
|
||||
onChange={(e) => setFps(Number(e.target.value))}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">
|
||||
{t('tools.videoToGif.width')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
max="640"
|
||||
step="10"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleUpload} className="btn-primary w-full">
|
||||
{t('tools.videoToGif.shortDesc')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && !result && (
|
||||
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{taskError}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/tools/WordCounter.tsx
Normal file
81
frontend/src/components/tools/WordCounter.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Hash } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { countText, type TextStats } from '@/utils/textTools';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
|
||||
export default function WordCounter() {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const stats: TextStats = countText(text);
|
||||
|
||||
const statItems = [
|
||||
{ label: t('tools.wordCounter.words'), value: stats.words, color: 'bg-blue-50 text-blue-700' },
|
||||
{ label: t('tools.wordCounter.characters'), value: stats.characters, color: 'bg-purple-50 text-purple-700' },
|
||||
{ label: t('tools.wordCounter.sentences'), value: stats.sentences, color: 'bg-emerald-50 text-emerald-700' },
|
||||
{ label: t('tools.wordCounter.paragraphs'), value: stats.paragraphs, color: 'bg-orange-50 text-orange-700' },
|
||||
];
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.wordCounter.title'),
|
||||
description: t('tools.wordCounter.description'),
|
||||
url: `${window.location.origin}/tools/word-counter`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.wordCounter.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.wordCounter.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/word-counter`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-100">
|
||||
<Hash className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.wordCounter.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.wordCounter.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{statItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`rounded-xl p-4 text-center ${item.color}`}
|
||||
>
|
||||
<p className="text-2xl font-bold">{item.value}</p>
|
||||
<p className="text-xs font-medium opacity-80">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reading Time */}
|
||||
{stats.words > 0 && (
|
||||
<p className="mb-4 text-center text-sm text-slate-500">
|
||||
📖 Reading time: {stats.readingTime}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Text Input */}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={t('tools.wordCounter.placeholder')}
|
||||
className="input-field min-h-[300px] resize-y font-mono text-sm"
|
||||
dir="auto"
|
||||
/>
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/tools/WordToPdf.tsx
Normal file
121
frontend/src/components/tools/WordToPdf.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { FileOutput } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
|
||||
export default function WordToPdf() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/convert/word-to-pdf',
|
||||
maxSizeMB: 15,
|
||||
acceptedTypes: ['doc', 'docx'],
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.wordToPdf.title'),
|
||||
description: t('tools.wordToPdf.description'),
|
||||
url: `${window.location.origin}/tools/word-to-pdf`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.wordToPdf.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.wordToPdf.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/word-to-pdf`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-100">
|
||||
<FileOutput className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.wordToPdf.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.wordToPdf.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
}}
|
||||
maxSizeMB={15}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="Word (.doc, .docx)"
|
||||
/>
|
||||
{file && !isUploading && (
|
||||
<button onClick={handleUpload} className="btn-primary w-full">
|
||||
{t('tools.wordToPdf.shortDesc')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && !result && (
|
||||
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{taskError}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
frontend/src/hooks/useDirection.ts
Normal file
20
frontend/src/hooks/useDirection.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Hook that manages the HTML dir attribute based on current language.
|
||||
*/
|
||||
export function useDirection() {
|
||||
const { i18n } = useTranslation();
|
||||
const isRTL = i18n.language === 'ar';
|
||||
|
||||
useEffect(() => {
|
||||
const dir = isRTL ? 'rtl' : 'ltr';
|
||||
const lang = i18n.language;
|
||||
|
||||
document.documentElement.setAttribute('dir', dir);
|
||||
document.documentElement.setAttribute('lang', lang);
|
||||
}, [i18n.language, isRTL]);
|
||||
|
||||
return { isRTL, language: i18n.language };
|
||||
}
|
||||
110
frontend/src/hooks/useFileUpload.ts
Normal file
110
frontend/src/hooks/useFileUpload.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { uploadFile, type TaskResponse } from '@/services/api';
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
endpoint: string;
|
||||
maxSizeMB?: number;
|
||||
acceptedTypes?: string[];
|
||||
extraData?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface UseFileUploadReturn {
|
||||
file: File | null;
|
||||
uploadProgress: number;
|
||||
isUploading: boolean;
|
||||
taskId: string | null;
|
||||
error: string | null;
|
||||
selectFile: (file: File) => void;
|
||||
startUpload: () => Promise<string | null>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useFileUpload({
|
||||
endpoint,
|
||||
maxSizeMB = 20,
|
||||
acceptedTypes,
|
||||
extraData,
|
||||
}: UseFileUploadOptions): UseFileUploadReturn {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const extraDataRef = useRef(extraData);
|
||||
extraDataRef.current = extraData;
|
||||
|
||||
const selectFile = useCallback(
|
||||
(selectedFile: File) => {
|
||||
setError(null);
|
||||
setTaskId(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
// Client-side size check
|
||||
const maxBytes = maxSizeMB * 1024 * 1024;
|
||||
if (selectedFile.size > maxBytes) {
|
||||
setError(`File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side type check
|
||||
if (acceptedTypes && acceptedTypes.length > 0) {
|
||||
const ext = selectedFile.name.split('.').pop()?.toLowerCase();
|
||||
if (!ext || !acceptedTypes.includes(ext)) {
|
||||
setError(`Invalid file type. Accepted: ${acceptedTypes.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
},
|
||||
[maxSizeMB, acceptedTypes]
|
||||
);
|
||||
|
||||
const startUpload = useCallback(async (): Promise<string | null> => {
|
||||
if (!file) {
|
||||
setError('No file selected.');
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const response: TaskResponse = await uploadFile(
|
||||
endpoint,
|
||||
file,
|
||||
extraDataRef.current,
|
||||
(percent) => setUploadProgress(percent)
|
||||
);
|
||||
|
||||
setTaskId(response.task_id);
|
||||
setIsUploading(false);
|
||||
return response.task_id;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed.';
|
||||
setError(message);
|
||||
setIsUploading(false);
|
||||
return null;
|
||||
}
|
||||
}, [file, endpoint]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFile(null);
|
||||
setUploadProgress(0);
|
||||
setIsUploading(false);
|
||||
setTaskId(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
87
frontend/src/hooks/useTaskPolling.ts
Normal file
87
frontend/src/hooks/useTaskPolling.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getTaskStatus, type TaskStatus, type TaskResult } from '@/services/api';
|
||||
|
||||
interface UseTaskPollingOptions {
|
||||
taskId: string | null;
|
||||
intervalMs?: number;
|
||||
onComplete?: (result: TaskResult) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
interface UseTaskPollingReturn {
|
||||
status: TaskStatus | null;
|
||||
isPolling: boolean;
|
||||
result: TaskResult | null;
|
||||
error: string | null;
|
||||
stopPolling: () => void;
|
||||
}
|
||||
|
||||
export function useTaskPolling({
|
||||
taskId,
|
||||
intervalMs = 1500,
|
||||
onComplete,
|
||||
onError,
|
||||
}: UseTaskPollingOptions): UseTaskPollingReturn {
|
||||
const [status, setStatus] = useState<TaskStatus | null>(null);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [result, setResult] = useState<TaskResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setIsPolling(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
|
||||
setIsPolling(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const taskStatus = await getTaskStatus(taskId);
|
||||
setStatus(taskStatus);
|
||||
|
||||
if (taskStatus.state === 'SUCCESS') {
|
||||
stopPolling();
|
||||
const taskResult = taskStatus.result;
|
||||
|
||||
if (taskResult?.status === 'completed') {
|
||||
setResult(taskResult);
|
||||
onComplete?.(taskResult);
|
||||
} else {
|
||||
const errMsg = taskResult?.error || 'Processing failed.';
|
||||
setError(errMsg);
|
||||
onError?.(errMsg);
|
||||
}
|
||||
} else if (taskStatus.state === 'FAILURE') {
|
||||
stopPolling();
|
||||
const errMsg = taskStatus.error || 'Task failed.';
|
||||
setError(errMsg);
|
||||
onError?.(errMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
stopPolling();
|
||||
const errMsg = err instanceof Error ? err.message : 'Polling failed.';
|
||||
setError(errMsg);
|
||||
onError?.(errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately, then set interval
|
||||
poll();
|
||||
intervalRef.current = setInterval(poll, intervalMs);
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [taskId, intervalMs]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { status, isPolling, result, error, stopPolling };
|
||||
}
|
||||
96
frontend/src/i18n/ar.json
Normal file
96
frontend/src/i18n/ar.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "SaaS-PDF",
|
||||
"tagline": "أدوات ملفات مجانية على الإنترنت",
|
||||
"upload": "رفع ملف",
|
||||
"download": "تحميل",
|
||||
"processing": "جاري المعالجة...",
|
||||
"dragDrop": "اسحب الملف وأفلته هنا، أو اضغط للاختيار",
|
||||
"maxSize": "الحد الأقصى لحجم الملف: {{size}} ميجابايت",
|
||||
"tryOtherTools": "جرب أدوات أخرى",
|
||||
"error": "خطأ",
|
||||
"success": "تم بنجاح",
|
||||
"loading": "جاري التحميل...",
|
||||
"startOver": "ابدأ من جديد",
|
||||
"home": "الرئيسية",
|
||||
"about": "عن الموقع",
|
||||
"privacy": "سياسة الخصوصية",
|
||||
"terms": "شروط الاستخدام",
|
||||
"language": "اللغة",
|
||||
"allTools": "كل الأدوات"
|
||||
},
|
||||
"home": {
|
||||
"hero": "حوّل ملفاتك فوراً",
|
||||
"heroSub": "أدوات مجانية لمعالجة ملفات PDF والصور والفيديو والنصوص. بدون تسجيل.",
|
||||
"popularTools": "الأدوات الشائعة",
|
||||
"pdfTools": "أدوات PDF",
|
||||
"imageTools": "أدوات الصور",
|
||||
"videoTools": "أدوات الفيديو",
|
||||
"textTools": "أدوات النصوص"
|
||||
},
|
||||
"tools": {
|
||||
"pdfToWord": {
|
||||
"title": "PDF إلى Word",
|
||||
"description": "حوّل ملفات PDF إلى مستندات Word قابلة للتعديل مجاناً.",
|
||||
"shortDesc": "PDF → Word"
|
||||
},
|
||||
"wordToPdf": {
|
||||
"title": "Word إلى PDF",
|
||||
"description": "حوّل مستندات Word (DOC, DOCX) إلى صيغة PDF مجاناً.",
|
||||
"shortDesc": "Word → PDF"
|
||||
},
|
||||
"compressPdf": {
|
||||
"title": "ضغط PDF",
|
||||
"description": "قلّل حجم ملف PDF مع الحفاظ على الجودة. اختر مستوى الضغط.",
|
||||
"shortDesc": "ضغط PDF",
|
||||
"qualityLow": "أقصى ضغط",
|
||||
"qualityMedium": "متوازن",
|
||||
"qualityHigh": "جودة عالية"
|
||||
},
|
||||
"imageConvert": {
|
||||
"title": "محوّل الصور",
|
||||
"description": "حوّل الصور بين صيغ JPG و PNG و WebP فوراً.",
|
||||
"shortDesc": "تحويل الصور"
|
||||
},
|
||||
"videoToGif": {
|
||||
"title": "فيديو إلى GIF",
|
||||
"description": "أنشئ صور GIF متحركة من مقاطع الفيديو. خصّص وقت البداية والمدة والجودة.",
|
||||
"shortDesc": "فيديو → GIF",
|
||||
"startTime": "وقت البداية (ثوانٍ)",
|
||||
"duration": "المدة (ثوانٍ)",
|
||||
"fps": "إطارات في الثانية",
|
||||
"width": "العرض (بكسل)"
|
||||
},
|
||||
"wordCounter": {
|
||||
"title": "عدّاد الكلمات",
|
||||
"description": "عُد الكلمات والحروف والجمل والفقرات في نصك فوراً.",
|
||||
"shortDesc": "عد الكلمات",
|
||||
"words": "كلمات",
|
||||
"characters": "حروف",
|
||||
"sentences": "جمل",
|
||||
"paragraphs": "فقرات",
|
||||
"placeholder": "اكتب أو الصق نصك هنا..."
|
||||
},
|
||||
"textCleaner": {
|
||||
"title": "منظّف النصوص",
|
||||
"description": "أزل المسافات الزائدة، حوّل حالة الحروف، ونظّف نصك فوراً.",
|
||||
"shortDesc": "تنظيف النص",
|
||||
"removeSpaces": "إزالة المسافات الزائدة",
|
||||
"toUpperCase": "أحرف كبيرة",
|
||||
"toLowerCase": "أحرف صغيرة",
|
||||
"toTitleCase": "حروف العنوان",
|
||||
"toSentenceCase": "حالة الجملة",
|
||||
"removeDiacritics": "إزالة التشكيل العربي",
|
||||
"copyResult": "نسخ النتيجة"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"conversionComplete": "اكتمل التحويل!",
|
||||
"compressionComplete": "اكتمل الضغط!",
|
||||
"originalSize": "الحجم الأصلي",
|
||||
"newSize": "الحجم الجديد",
|
||||
"reduction": "نسبة التقليل",
|
||||
"downloadReady": "ملفك جاهز للتحميل.",
|
||||
"linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة."
|
||||
}
|
||||
}
|
||||
96
frontend/src/i18n/en.json
Normal file
96
frontend/src/i18n/en.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "SaaS-PDF",
|
||||
"tagline": "Free Online File Tools",
|
||||
"upload": "Upload File",
|
||||
"download": "Download",
|
||||
"processing": "Processing...",
|
||||
"dragDrop": "Drag & drop your file here, or click to browse",
|
||||
"maxSize": "Maximum file size: {{size}}MB",
|
||||
"tryOtherTools": "Try Other Tools",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"loading": "Loading...",
|
||||
"startOver": "Start Over",
|
||||
"home": "Home",
|
||||
"about": "About",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Service",
|
||||
"language": "Language",
|
||||
"allTools": "All Tools"
|
||||
},
|
||||
"home": {
|
||||
"hero": "Transform Your Files Instantly",
|
||||
"heroSub": "Free online tools for PDF, image, video, and text processing. No registration required.",
|
||||
"popularTools": "Popular Tools",
|
||||
"pdfTools": "PDF Tools",
|
||||
"imageTools": "Image Tools",
|
||||
"videoTools": "Video Tools",
|
||||
"textTools": "Text Tools"
|
||||
},
|
||||
"tools": {
|
||||
"pdfToWord": {
|
||||
"title": "PDF to Word",
|
||||
"description": "Convert PDF files to editable Word documents online for free.",
|
||||
"shortDesc": "PDF → Word"
|
||||
},
|
||||
"wordToPdf": {
|
||||
"title": "Word to PDF",
|
||||
"description": "Convert Word documents (DOC, DOCX) to PDF format online for free.",
|
||||
"shortDesc": "Word → PDF"
|
||||
},
|
||||
"compressPdf": {
|
||||
"title": "Compress PDF",
|
||||
"description": "Reduce PDF file size while maintaining quality. Choose your compression level.",
|
||||
"shortDesc": "Compress PDF",
|
||||
"qualityLow": "Maximum Compression",
|
||||
"qualityMedium": "Balanced",
|
||||
"qualityHigh": "High Quality"
|
||||
},
|
||||
"imageConvert": {
|
||||
"title": "Image Converter",
|
||||
"description": "Convert images between JPG, PNG, and WebP formats instantly.",
|
||||
"shortDesc": "Convert Images"
|
||||
},
|
||||
"videoToGif": {
|
||||
"title": "Video to GIF",
|
||||
"description": "Create animated GIFs from video clips. Customize start time, duration, and quality.",
|
||||
"shortDesc": "Video → GIF",
|
||||
"startTime": "Start Time (seconds)",
|
||||
"duration": "Duration (seconds)",
|
||||
"fps": "Frames Per Second",
|
||||
"width": "Width (pixels)"
|
||||
},
|
||||
"wordCounter": {
|
||||
"title": "Word Counter",
|
||||
"description": "Count words, characters, sentences, and paragraphs in your text instantly.",
|
||||
"shortDesc": "Count Words",
|
||||
"words": "Words",
|
||||
"characters": "Characters",
|
||||
"sentences": "Sentences",
|
||||
"paragraphs": "Paragraphs",
|
||||
"placeholder": "Type or paste your text here..."
|
||||
},
|
||||
"textCleaner": {
|
||||
"title": "Text Cleaner",
|
||||
"description": "Remove extra spaces, convert text case, and clean up your text instantly.",
|
||||
"shortDesc": "Clean Text",
|
||||
"removeSpaces": "Remove Extra Spaces",
|
||||
"toUpperCase": "UPPER CASE",
|
||||
"toLowerCase": "lower case",
|
||||
"toTitleCase": "Title Case",
|
||||
"toSentenceCase": "Sentence case",
|
||||
"removeDiacritics": "Remove Arabic Diacritics",
|
||||
"copyResult": "Copy Result"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"conversionComplete": "Conversion Complete!",
|
||||
"compressionComplete": "Compression Complete!",
|
||||
"originalSize": "Original Size",
|
||||
"newSize": "New Size",
|
||||
"reduction": "Reduction",
|
||||
"downloadReady": "Your file is ready for download.",
|
||||
"linkExpiry": "Download link expires in 30 minutes."
|
||||
}
|
||||
}
|
||||
27
frontend/src/i18n/index.ts
Normal file
27
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import en from './en.json';
|
||||
import ar from './ar.json';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ar: { translation: ar },
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'ar'],
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
|
||||
caches: ['localStorage', 'cookie'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<HelmetProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</HelmetProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
49
frontend/src/pages/AboutPage.tsx
Normal file
49
frontend/src/pages/AboutPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('common.about')} — {t('common.appName')}</title>
|
||||
<meta name="description" content="About our free online file conversion tools." />
|
||||
</Helmet>
|
||||
|
||||
<div className="prose mx-auto max-w-2xl dark:prose-invert">
|
||||
<h1>{t('common.about')}</h1>
|
||||
|
||||
<p>
|
||||
We provide free, fast, and secure online tools for converting, compressing,
|
||||
and processing files — PDFs, images, videos, and text.
|
||||
</p>
|
||||
|
||||
<h2>Why use our tools?</h2>
|
||||
<ul>
|
||||
<li><strong>100% Free</strong> — No hidden charges, no sign-up required.</li>
|
||||
<li><strong>Private & Secure</strong> — Files are auto-deleted within 2 hours.</li>
|
||||
<li><strong>Fast Processing</strong> — Server-side processing for reliable results.</li>
|
||||
<li><strong>Works Everywhere</strong> — Desktop, tablet, or mobile.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Available Tools</h2>
|
||||
<ul>
|
||||
<li>PDF to Word Converter</li>
|
||||
<li>Word to PDF Converter</li>
|
||||
<li>PDF Compressor</li>
|
||||
<li>Image Format Converter</li>
|
||||
<li>Video to GIF Creator</li>
|
||||
<li>Word Counter</li>
|
||||
<li>Text Cleaner & Formatter</li>
|
||||
</ul>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Have feedback or feature requests? Reach out at{' '}
|
||||
<a href="mailto:support@example.com">support@example.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/HomePage.tsx
Normal file
93
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import {
|
||||
FileText,
|
||||
FileOutput,
|
||||
Minimize2,
|
||||
ImageIcon,
|
||||
Film,
|
||||
Hash,
|
||||
Eraser,
|
||||
} from 'lucide-react';
|
||||
import ToolCard from '@/components/shared/ToolCard';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
|
||||
interface ToolInfo {
|
||||
key: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
const tools: ToolInfo[] = [
|
||||
{ key: 'pdfToWord', path: '/tools/pdf-to-word', icon: <FileText className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' },
|
||||
{ key: 'wordToPdf', path: '/tools/word-to-pdf', icon: <FileOutput className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
|
||||
{ key: 'compressPdf', path: '/tools/compress-pdf', icon: <Minimize2 className="h-6 w-6 text-orange-600" />, bgColor: 'bg-orange-50' },
|
||||
{ key: 'imageConvert', path: '/tools/image-converter', icon: <ImageIcon className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' },
|
||||
{ key: 'videoToGif', path: '/tools/video-to-gif', icon: <Film className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' },
|
||||
{ key: 'wordCounter', path: '/tools/word-counter', icon: <Hash className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
|
||||
{ key: 'textCleaner', path: '/tools/text-cleaner', icon: <Eraser className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('common.appName')} — {t('home.heroSub')}</title>
|
||||
<meta name="description" content={t('home.heroSub')} />
|
||||
<link rel="canonical" href={window.location.origin} />
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: t('common.appName'),
|
||||
url: window.location.origin,
|
||||
description: t('home.heroSub'),
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${window.location.origin}/tools/{search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="py-12 text-center sm:py-16">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
|
||||
{t('home.hero')}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-xl text-lg text-slate-500">
|
||||
{t('home.heroSub')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Ad Slot */}
|
||||
<AdSlot slot="home-top" format="horizontal" className="mb-8" />
|
||||
|
||||
{/* Tools Grid */}
|
||||
<section>
|
||||
<h2 className="mb-6 text-center text-xl font-semibold text-slate-800">
|
||||
{t('home.popularTools')}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tools.map((tool) => (
|
||||
<ToolCard
|
||||
key={tool.key}
|
||||
to={tool.path}
|
||||
icon={tool.icon}
|
||||
title={t(`tools.${tool.key}.title`)}
|
||||
description={t(`tools.${tool.key}.shortDesc`)}
|
||||
bgColor={tool.bgColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ad Slot - Bottom */}
|
||||
<AdSlot slot="home-bottom" className="mt-12" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
frontend/src/pages/NotFoundPage.tsx
Normal file
34
frontend/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Home } from 'lucide-react';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>404 — {t('common.appName')}</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
</Helmet>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<p className="text-7xl font-bold text-primary-600">404</p>
|
||||
<h1 className="mt-4 text-2xl font-semibold text-slate-900">
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-500">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="btn-primary mt-8 inline-flex items-center gap-2"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
{t('common.home')}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
frontend/src/pages/PrivacyPage.tsx
Normal file
59
frontend/src/pages/PrivacyPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('common.privacy')} — {t('common.appName')}</title>
|
||||
<meta name="description" content="Privacy policy for our online tools." />
|
||||
</Helmet>
|
||||
|
||||
<div className="prose mx-auto max-w-2xl dark:prose-invert">
|
||||
<h1>{t('common.privacy')}</h1>
|
||||
<p><em>Last updated: {new Date().toISOString().split('T')[0]}</em></p>
|
||||
|
||||
<h2>1. Data Collection</h2>
|
||||
<p>
|
||||
We only collect files you intentionally upload for processing. We do not
|
||||
require registration, and we do not store personal information.
|
||||
</p>
|
||||
|
||||
<h2>2. File Processing & Storage</h2>
|
||||
<ul>
|
||||
<li>Uploaded files are processed on our secure servers.</li>
|
||||
<li>All uploaded and output files are <strong>automatically deleted within 2 hours</strong>.</li>
|
||||
<li>Files are stored in encrypted cloud storage during processing.</li>
|
||||
<li>We do not access, read, or share the content of your files.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Cookies & Analytics</h2>
|
||||
<p>
|
||||
We use essential cookies to remember your language preference. We may use
|
||||
Google Analytics and Google AdSense, which may place their own cookies.
|
||||
You can manage cookie preferences in your browser settings.
|
||||
</p>
|
||||
|
||||
<h2>4. Third-Party Services</h2>
|
||||
<ul>
|
||||
<li><strong>Google AdSense</strong> — for displaying advertisements.</li>
|
||||
<li><strong>AWS S3</strong> — for temporary file storage.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Security</h2>
|
||||
<p>
|
||||
We employ industry-standard security measures including HTTPS encryption,
|
||||
file validation, rate limiting, and automatic file cleanup.
|
||||
</p>
|
||||
|
||||
<h2>6. Contact</h2>
|
||||
<p>
|
||||
Questions about this policy? Contact us at{' '}
|
||||
<a href="mailto:support@example.com">support@example.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
frontend/src/pages/TermsPage.tsx
Normal file
66
frontend/src/pages/TermsPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
export default function TermsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('common.terms')} — {t('common.appName')}</title>
|
||||
<meta name="description" content="Terms of service for our online tools." />
|
||||
</Helmet>
|
||||
|
||||
<div className="prose mx-auto max-w-2xl dark:prose-invert">
|
||||
<h1>{t('common.terms')}</h1>
|
||||
<p><em>Last updated: {new Date().toISOString().split('T')[0]}</em></p>
|
||||
|
||||
<h2>1. Acceptance of Terms</h2>
|
||||
<p>
|
||||
By accessing and using SaaS-PDF, you agree to be bound by these Terms of
|
||||
Service. If you do not agree, please discontinue use immediately.
|
||||
</p>
|
||||
|
||||
<h2>2. Service Description</h2>
|
||||
<p>
|
||||
SaaS-PDF provides free online tools for file conversion, compression,
|
||||
and transformation. The service is provided “as is” without
|
||||
warranties of any kind.
|
||||
</p>
|
||||
|
||||
<h2>3. Acceptable Use</h2>
|
||||
<ul>
|
||||
<li>You may only upload files that you have the right to process.</li>
|
||||
<li>You must not upload malicious, illegal, or copyrighted content without authorization.</li>
|
||||
<li>Automated or excessive use of the service is prohibited.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. File Handling</h2>
|
||||
<ul>
|
||||
<li>All uploaded and processed files are automatically deleted within 2 hours.</li>
|
||||
<li>We are not responsible for any data loss during processing.</li>
|
||||
<li>You are responsible for maintaining your own file backups.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Limitation of Liability</h2>
|
||||
<p>
|
||||
SaaS-PDF shall not be liable for any direct, indirect, incidental, or
|
||||
consequential damages resulting from the use or inability to use the
|
||||
service.
|
||||
</p>
|
||||
|
||||
<h2>6. Changes to Terms</h2>
|
||||
<p>
|
||||
We reserve the right to modify these terms at any time. Continued use of
|
||||
the service after changes constitutes acceptance of the updated terms.
|
||||
</p>
|
||||
|
||||
<h2>7. Contact</h2>
|
||||
<p>
|
||||
Questions about these terms? Contact us at{' '}
|
||||
<a href="mailto:support@example.com">support@example.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
frontend/src/services/api.ts
Normal file
114
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 120000, // 2 minute timeout for file processing
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for logging
|
||||
api.interceptors.request.use(
|
||||
(config) => config,
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const message = error.response.data?.error || 'An error occurred.';
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
if (error.request) {
|
||||
return Promise.reject(new Error('Network error. Please check your connection.'));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// --- API Functions ---
|
||||
|
||||
export interface TaskResponse {
|
||||
task_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TaskStatus {
|
||||
task_id: string;
|
||||
state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE';
|
||||
progress?: string;
|
||||
result?: TaskResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TaskResult {
|
||||
status: 'completed' | 'failed';
|
||||
download_url?: string;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
original_size?: number;
|
||||
compressed_size?: number;
|
||||
reduction_percent?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
output_size?: number;
|
||||
duration?: number;
|
||||
fps?: number;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file and start a processing task.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
endpoint: string,
|
||||
file: File,
|
||||
extraData?: Record<string, string>,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<TaskResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (extraData) {
|
||||
Object.entries(extraData).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
const response = await api.post<TaskResponse>(endpoint, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (event) => {
|
||||
if (event.total && onProgress) {
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(percent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll task status.
|
||||
*/
|
||||
export async function getTaskStatus(taskId: string): Promise<TaskStatus> {
|
||||
const response = await api.get<TaskStatus>(`/tasks/${taskId}/status`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API health.
|
||||
*/
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.get('/health');
|
||||
return response.data.status === 'healthy';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default api;
|
||||
90
frontend/src/styles/global.css
Normal file
90
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,90 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f8fafc;
|
||||
--color-text: #0f172a;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-border: #e2e8f0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-slate-900 antialiased;
|
||||
font-family: 'Inter', 'Tajawal', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* RTL Support */
|
||||
[dir="rtl"] body {
|
||||
font-family: 'Tajawal', 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[dir="rtl"] .ltr-only {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-sm font-semibold text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300 transition-all hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 transition-shadow hover:shadow-md;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
@apply card cursor-pointer hover:ring-primary-300 hover:shadow-lg transition-all duration-200;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply block w-full rounded-xl border-0 py-3 px-4 text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
@apply text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl;
|
||||
}
|
||||
}
|
||||
|
||||
/* Upload zone styles */
|
||||
.upload-zone {
|
||||
@apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 p-8 text-center transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.upload-zone:hover,
|
||||
.upload-zone.drag-active {
|
||||
@apply border-primary-400 bg-primary-50;
|
||||
}
|
||||
|
||||
.upload-zone.drag-active {
|
||||
@apply ring-2 ring-primary-300;
|
||||
}
|
||||
|
||||
/* Progress bar animation */
|
||||
@keyframes progress-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.progress-bar-animated {
|
||||
animation: progress-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Ad slot container */
|
||||
.ad-slot {
|
||||
@apply flex items-center justify-center bg-slate-50 rounded-xl border border-slate-200 min-h-[90px] overflow-hidden;
|
||||
}
|
||||
69
frontend/src/utils/seo.ts
Normal file
69
frontend/src/utils/seo.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* SEO utility functions for structured data generation.
|
||||
*/
|
||||
|
||||
export interface ToolSeoData {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate WebApplication JSON-LD structured data for a tool page.
|
||||
*/
|
||||
export function generateToolSchema(tool: ToolSeoData): object {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: tool.name,
|
||||
url: tool.url,
|
||||
applicationCategory: tool.category || 'UtilitiesApplication',
|
||||
operatingSystem: 'Any',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
description: tool.description,
|
||||
inLanguage: ['en', 'ar'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate BreadcrumbList JSON-LD.
|
||||
*/
|
||||
export function generateBreadcrumbs(
|
||||
items: { name: string; url: string }[]
|
||||
): object {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: item.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate FAQ structured data.
|
||||
*/
|
||||
export function generateFAQ(
|
||||
questions: { question: string; answer: string }[]
|
||||
): object {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: questions.map((q) => ({
|
||||
'@type': 'Question',
|
||||
name: q.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: q.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
124
frontend/src/utils/textTools.ts
Normal file
124
frontend/src/utils/textTools.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Client-side text processing utilities.
|
||||
* These run entirely in the browser — no API calls needed.
|
||||
*/
|
||||
|
||||
export interface TextStats {
|
||||
words: number;
|
||||
characters: number;
|
||||
charactersNoSpaces: number;
|
||||
sentences: number;
|
||||
paragraphs: number;
|
||||
readingTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count words, characters, sentences, and paragraphs.
|
||||
* Supports both English and Arabic text.
|
||||
*/
|
||||
export function countText(text: string): TextStats {
|
||||
if (!text.trim()) {
|
||||
return {
|
||||
words: 0,
|
||||
characters: 0,
|
||||
charactersNoSpaces: 0,
|
||||
sentences: 0,
|
||||
paragraphs: 0,
|
||||
readingTime: '0 min',
|
||||
};
|
||||
}
|
||||
|
||||
const characters = text.length;
|
||||
const charactersNoSpaces = text.replace(/\s/g, '').length;
|
||||
|
||||
// Word count — split by whitespace, filter empty
|
||||
const words = text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0).length;
|
||||
|
||||
// Sentence count — split by sentence-ending punctuation
|
||||
const sentences = text
|
||||
.split(/[.!?؟。]+/)
|
||||
.filter((s) => s.trim().length > 0).length;
|
||||
|
||||
// Paragraph count — split by double newlines or single newlines
|
||||
const paragraphs = text
|
||||
.split(/\n\s*\n|\n/)
|
||||
.filter((p) => p.trim().length > 0).length;
|
||||
|
||||
// Reading time (avg 200 words/min for English, 150 for Arabic)
|
||||
const avgWPM = 180;
|
||||
const minutes = Math.ceil(words / avgWPM);
|
||||
const readingTime = minutes <= 1 ? '< 1 min' : `${minutes} min`;
|
||||
|
||||
return {
|
||||
words,
|
||||
characters,
|
||||
charactersNoSpaces,
|
||||
sentences,
|
||||
paragraphs,
|
||||
readingTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove extra whitespace (multiple spaces, tabs, etc.)
|
||||
*/
|
||||
export function removeExtraSpaces(text: string): string {
|
||||
return text
|
||||
.replace(/[^\S\n]+/g, ' ') // multiple spaces → single space
|
||||
.replace(/\n{3,}/g, '\n\n') // 3+ newlines → 2
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert text case.
|
||||
*/
|
||||
export function convertCase(
|
||||
text: string,
|
||||
type: 'upper' | 'lower' | 'title' | 'sentence'
|
||||
): string {
|
||||
switch (type) {
|
||||
case 'upper':
|
||||
return text.toUpperCase();
|
||||
|
||||
case 'lower':
|
||||
return text.toLowerCase();
|
||||
|
||||
case 'title':
|
||||
return text.replace(
|
||||
/\w\S*/g,
|
||||
(txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()
|
||||
);
|
||||
|
||||
case 'sentence':
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/(^\s*\w|[.!?؟]\s*\w)/g, (match) => match.toUpperCase());
|
||||
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Arabic diacritics (tashkeel) from text.
|
||||
*/
|
||||
export function removeDiacritics(text: string): string {
|
||||
// Arabic diacritics Unicode range: \u064B-\u065F, \u0670
|
||||
return text.replace(/[\u064B-\u065F\u0670]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human-readable form.
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${units[i]}`;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user