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:
Your Name
2026-02-28 23:31:19 +02:00
parent 3b84ebb916
commit 85d98381df
93 changed files with 5940 additions and 0 deletions

41
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# ---- Build Stage ----
FROM node:20-alpine AS build
WORKDIR /app
# Install dependencies
COPY package.json ./
RUN npm install
# Copy source code
COPY . .
# Build for production
RUN npm run build
# ---- Production Stage ----
FROM nginx:alpine AS production
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx config for SPA routing
COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# ---- Development Stage ----
FROM node:20-alpine AS development
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Free online tools for PDF, image, video, and text processing. Convert, compress, and transform your files instantly." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Tajawal:wght@300;400;500;700&display=swap" rel="stylesheet" />
<title>SaaS-PDF — Free Online File Tools</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "saas-pdf-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"axios": "^1.7.0",
"i18next": "^23.11.0",
"i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^0.400.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-dropzone": "^14.2.0",
"react-ga4": "^2.1.0",
"react-helmet-async": "^2.0.0",
"react-i18next": "^14.1.0",
"react-router-dom": "^6.23.0",
"sonner": "^1.5.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1
frontend/public/ads.txt Normal file
View File

@@ -0,0 +1 @@
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#4F46E5"/>
<path d="M18 20h20a2 2 0 0 1 2 2v20a2 2 0 0 1-2 2H18a2 2 0 0 1-2-2V22a2 2 0 0 1 2-2z" stroke="#fff" stroke-width="2.5" fill="none"/>
<path d="M22 28h12M22 33h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
<path d="M42 24l6-6M48 18v6h-6" stroke="#93C5FD" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M42 40l6 6M48 46v-6h-6" stroke="#93C5FD" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1,6 @@
# robots.txt — SaaS-PDF
User-agent: *
Allow: /
Disallow: /api/
Sitemap: https://yourdomain.com/sitemap.xml

71
frontend/src/App.tsx Normal file
View 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>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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
View 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."
}
}

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

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

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

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

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

View 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 &ldquo;as is&rdquo; 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>
</>
);
}

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

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

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

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,44 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
accent: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
},
},
fontFamily: {
sans: ['Inter', 'Tajawal', 'system-ui', 'sans-serif'],
arabic: ['Tajawal', 'Inter', 'sans-serif'],
},
},
},
plugins: [],
};

23
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

34
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
host: true,
proxy: {
'/api': {
target: 'http://backend:5000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
i18n: ['i18next', 'react-i18next'],
},
},
},
},
});