إنجاز: تفعيل خاصية استعادة كلمة المرور وإعادة تعيينها

- إضافة نقاط نهاية لخاصيتي استعادة كلمة المرور وإعادة تعيينها في الواجهة الخلفية.

- إنشاء اختبارات لخاصية إعادة تعيين كلمة المرور لضمان كفاءتها وأمانها.

- تطوير صفحات واجهة المستخدم لخاصيتي استعادة كلمة المرور وإعادة تعيينها مع معالجة النماذج.

- دمج حدود تحميل ديناميكية لأنواع ملفات مختلفة بناءً على خطط المستخدمين.

- تقديم أداة جديدة لتغيير حجم الصور مع إمكانية تعديل الأبعاد وإعدادات الجودة.

- تحديث نظام التوجيه والتنقل ليشمل أدوات جديدة وميزات مصادقة.

- تحسين تجربة المستخدم من خلال معالجة الأخطاء ورسائل التغذية الراجعة المناسبة.

- إضافة دعم التدويل للميزات الجديدة باللغات الإنجليزية والعربية والفرنسية.
This commit is contained in:
Your Name
2026-03-07 14:23:50 +02:00
parent 0ad2ba0f02
commit 71f7d0382d
27 changed files with 1460 additions and 7 deletions

View File

@@ -13,6 +13,8 @@ const PrivacyPage = lazy(() => import('@/pages/PrivacyPage'));
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'));
const TermsPage = lazy(() => import('@/pages/TermsPage'));
const AccountPage = lazy(() => import('@/pages/AccountPage'));
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
// Tool Pages
const PdfToWord = lazy(() => import('@/components/tools/PdfToWord'));
@@ -33,6 +35,7 @@ const UnlockPdf = lazy(() => import('@/components/tools/UnlockPdf'));
const AddPageNumbers = lazy(() => import('@/components/tools/AddPageNumbers'));
const PdfEditor = lazy(() => import('@/components/tools/PdfEditor'));
const PdfFlowchart = lazy(() => import('@/components/tools/PdfFlowchart'));
const ImageResize = lazy(() => import('@/components/tools/ImageResize'));
function LoadingFallback() {
return (
@@ -67,6 +70,8 @@ export default function App() {
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms" element={<TermsPage />} />
@@ -88,6 +93,7 @@ export default function App() {
{/* Image Tools */}
<Route path="/tools/image-converter" element={<ImageConverter />} />
<Route path="/tools/image-resize" element={<ImageResize />} />
{/* Video Tools */}
<Route path="/tools/video-to-gif" element={<VideoToGif />} />

View File

@@ -7,7 +7,7 @@ import ToolSelectorModal from '@/components/shared/ToolSelectorModal';
import { useFileStore } from '@/stores/fileStore';
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
import type { ToolOption } from '@/utils/fileRouting';
import { TOOL_LIMITS_MB } from '@/config/toolLimits';
import { useConfig } from '@/hooks/useConfig';
/**
* The MIME types we accept on the homepage smart upload zone.
@@ -28,6 +28,7 @@ export default function HeroUploadZone() {
const { t } = useTranslation();
const navigate = useNavigate();
const setStoreFile = useFileStore((s) => s.setFile);
const { limits } = useConfig();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [matchedTools, setMatchedTools] = useState<ToolOption[]>([]);
const [fileTypeLabel, setFileTypeLabel] = useState('');
@@ -63,11 +64,11 @@ export default function HeroUploadZone() {
onDrop,
accept: ACCEPTED_TYPES,
maxFiles: 1,
maxSize: TOOL_LIMITS_MB.homepageSmartUpload * 1024 * 1024,
maxSize: limits.homepageSmartUpload * 1024 * 1024,
onDropRejected: (rejections) => {
const rejection = rejections[0];
if (rejection?.errors[0]?.code === 'file-too-large') {
setError(t('common.maxSize', { size: TOOL_LIMITS_MB.homepageSmartUpload }));
setError(t('common.maxSize', { size: limits.homepageSmartUpload }));
} else {
setError(t('home.unsupportedFile'));
}

View File

@@ -0,0 +1,231 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Scaling } 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';
import { useFileStore } from '@/stores/fileStore';
import { useConfig } from '@/hooks/useConfig';
export default function ImageResize() {
const { t } = useTranslation();
const { limits } = useConfig();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const [width, setWidth] = useState('');
const [height, setHeight] = useState('');
const [quality, setQuality] = useState(85);
const [lockAspect, setLockAspect] = useState(true);
const {
file,
uploadProgress,
isUploading,
taskId,
error: uploadError,
selectFile,
startUpload,
reset,
} = useFileUpload({
endpoint: '/image/resize',
maxSizeMB: limits.image,
acceptedTypes: ['png', 'jpg', 'jpeg', 'webp'],
extraData: {
...(width ? { width } : {}),
...(height ? { height } : {}),
quality: quality.toString(),
},
});
const { status, result, error: taskError } = useTaskPolling({
taskId,
onComplete: () => setPhase('done'),
onError: () => setPhase('done'),
});
// Accept file from homepage smart upload
const storeFile = useFileStore((s) => s.file);
const clearStoreFile = useFileStore((s) => s.clearFile);
useEffect(() => {
if (storeFile) {
selectFile(storeFile);
clearStoreFile();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleUpload = async () => {
if (!width && !height) return;
const id = await startUpload();
if (id) setPhase('processing');
};
const handleReset = () => {
reset();
setPhase('upload');
setWidth('');
setHeight('');
};
const dimensionValid = width || height;
const schema = generateToolSchema({
name: t('tools.imageResize.title'),
description: t('tools.imageResize.description'),
url: `${window.location.origin}/tools/image-resize`,
});
return (
<>
<Helmet>
<title>{t('tools.imageResize.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.imageResize.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/image-resize`} />
<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-teal-100 dark:bg-teal-900/30">
<Scaling className="h-8 w-8 text-teal-600 dark:text-teal-400" />
</div>
<h1 className="section-heading">{t('tools.imageResize.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">{t('tools.imageResize.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={limits.image}
isUploading={isUploading}
uploadProgress={uploadProgress}
error={uploadError}
onReset={handleReset}
acceptLabel="Images (PNG, JPG, WebP)"
/>
{file && !isUploading && (
<>
{/* Dimensions */}
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{t('tools.imageResize.dimensions')}
</span>
<label className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<input
type="checkbox"
checked={lockAspect}
onChange={(e) => setLockAspect(e.target.checked)}
className="accent-primary-600"
/>
{t('tools.imageResize.lockAspect')}
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
{t('tools.imageResize.width')}
</label>
<input
type="number"
min="1"
max="10000"
placeholder="e.g. 800"
value={width}
onChange={(e) => {
setWidth(e.target.value);
if (lockAspect) setHeight('');
}}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
{t('tools.imageResize.height')}
</label>
<input
type="number"
min="1"
max="10000"
placeholder="e.g. 600"
value={height}
onChange={(e) => {
setHeight(e.target.value);
if (lockAspect) setWidth('');
}}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
/>
</div>
</div>
{lockAspect && (
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
{t('tools.imageResize.aspectHint')}
</p>
)}
</div>
{/* Quality Slider */}
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<label className="mb-2 flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-300">
<span>{t('tools.imageResize.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}
disabled={!dimensionValid}
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('tools.imageResize.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 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{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

@@ -16,10 +16,11 @@ import { useFileUpload } from '@/hooks/useFileUpload';
import { useTaskPolling } from '@/hooks/useTaskPolling';
import { generateToolSchema } from '@/utils/seo';
import { useFileStore } from '@/stores/fileStore';
import { TOOL_LIMITS_MB } from '@/config/toolLimits';
import { useConfig } from '@/hooks/useConfig';
export default function PdfEditor() {
const { t } = useTranslation();
const { limits } = useConfig();
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const {
@@ -33,7 +34,7 @@ export default function PdfEditor() {
reset,
} = useFileUpload({
endpoint: '/compress/pdf',
maxSizeMB: TOOL_LIMITS_MB.pdf,
maxSizeMB: limits.pdf,
acceptedTypes: ['pdf'],
extraData: { quality: 'high' },
});
@@ -100,7 +101,7 @@ export default function PdfEditor() {
onFileSelect={selectFile}
file={file}
accept={{ 'application/pdf': ['.pdf'] }}
maxSizeMB={TOOL_LIMITS_MB.pdf}
maxSizeMB={limits.pdf}
isUploading={isUploading}
uploadProgress={uploadProgress}
error={uploadError}

View File

@@ -0,0 +1,45 @@
import { useState, useEffect, useCallback } from 'react';
import { TOOL_LIMITS_MB } from '@/config/toolLimits';
interface FileLimitsMb {
pdf: number;
word: number;
image: number;
video: number;
homepageSmartUpload: number;
}
interface ConfigData {
file_limits_mb: FileLimitsMb;
max_upload_mb: number;
}
const API_BASE = import.meta.env.VITE_API_URL || '';
/**
* Fetches dynamic upload limits from /api/config.
* Falls back to the hardcoded TOOL_LIMITS_MB on error.
*/
export function useConfig() {
const [limits, setLimits] = useState<FileLimitsMb>(TOOL_LIMITS_MB);
const [loading, setLoading] = useState(true);
const fetchConfig = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/config`, { credentials: 'include' });
if (!res.ok) throw new Error('config fetch failed');
const data: ConfigData = await res.json();
setLimits(data.file_limits_mb);
} catch {
// Keep hardcoded fallback
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
return { limits, loading, refetch: fetchConfig };
}

View File

@@ -25,6 +25,25 @@
"darkMode": "الوضع الداكن",
"lightMode": "الوضع الفاتح"
},
"auth": {
"forgotPassword": {
"title": "نسيت كلمة المرور",
"subtitle": "أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.",
"submit": "إرسال رابط التعيين",
"sent": "إذا كان هذا البريد مسجلاً، فقد تم إرسال رابط إعادة التعيين. تحقق من بريدك.",
"error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
"link": "نسيت كلمة المرور؟"
},
"resetPassword": {
"title": "إعادة تعيين كلمة المرور",
"newPassword": "كلمة المرور الجديدة",
"submit": "إعادة التعيين",
"success": "تم تحديث كلمة المرور بنجاح! جارٍ التوجيه لتسجيل الدخول...",
"error": "فشل إعادة التعيين. قد يكون الرابط منتهي الصلاحية.",
"tooShort": "يجب أن تكون كلمة المرور 8 أحرف على الأقل.",
"noToken": "رابط غير صالح. يرجى طلب رابط جديد."
}
},
"home": {
"hero": "كل ما تحتاجه للتعامل مع ملفات PDF — فوراً وبخطوات بسيطة",
"heroSub": "ارفع ملفك أو اسحبه هنا، وسنكتشف نوعه تلقائيًا ونقترح الأدوات الملائمة — التحرير، التحويل، الضغط وغير ذلك. لا حاجة لتسجيل حساب لبدء الاستخدام.",
@@ -80,6 +99,17 @@
"description": "حوّل الصور بين صيغ JPG و PNG و WebP فوراً.",
"shortDesc": "تحويل الصور"
},
"imageResize": {
"title": "تغيير حجم الصورة",
"description": "غيّر أبعاد الصور بدقة مع الحفاظ على الجودة.",
"shortDesc": "تغيير الحجم",
"dimensions": "الأبعاد المطلوبة",
"width": "العرض (بكسل)",
"height": "الارتفاع (بكسل)",
"quality": "الجودة",
"lockAspect": "قفل نسبة العرض للارتفاع",
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع."
},
"videoToGif": {
"title": "فيديو إلى GIF",
"description": "أنشئ صور GIF متحركة من مقاطع الفيديو. خصّص وقت البداية والمدة والجودة.",

View File

@@ -25,6 +25,25 @@
"darkMode": "Dark Mode",
"lightMode": "Light Mode"
},
"auth": {
"forgotPassword": {
"title": "Forgot Password",
"subtitle": "Enter your email and we'll send you a reset link.",
"submit": "Send Reset Link",
"sent": "If that email is registered, a reset link has been sent. Check your inbox.",
"error": "Something went wrong. Please try again.",
"link": "Forgot your password?"
},
"resetPassword": {
"title": "Reset Password",
"newPassword": "New Password",
"submit": "Reset Password",
"success": "Password updated successfully! Redirecting to sign in...",
"error": "Failed to reset password. The link may have expired.",
"tooShort": "Password must be at least 8 characters.",
"noToken": "Invalid reset link. Please request a new one."
}
},
"home": {
"hero": "Everything You Need to Work with PDF Files — Instantly",
"heroSub": "Upload or drag & drop your file, and we'll auto-detect its type and suggest the right tools — edit, convert, compress, and more. No registration required.",
@@ -80,6 +99,17 @@
"description": "Convert images between JPG, PNG, and WebP formats instantly.",
"shortDesc": "Convert Images"
},
"imageResize": {
"title": "Image Resize",
"description": "Resize images to exact dimensions while maintaining quality.",
"shortDesc": "Resize Image",
"dimensions": "Target Dimensions",
"width": "Width (px)",
"height": "Height (px)",
"quality": "Quality",
"lockAspect": "Lock aspect ratio",
"aspectHint": "Enter one dimension — the other will auto-calculate to preserve aspect ratio."
},
"videoToGif": {
"title": "Video to GIF",
"description": "Create animated GIFs from video clips. Customize start time, duration, and quality.",

View File

@@ -25,6 +25,25 @@
"darkMode": "Mode sombre",
"lightMode": "Mode clair"
},
"auth": {
"forgotPassword": {
"title": "Mot de passe oublié",
"subtitle": "Entrez votre email et nous vous enverrons un lien de réinitialisation.",
"submit": "Envoyer le lien",
"sent": "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Vérifiez votre boîte de réception.",
"error": "Une erreur s'est produite. Veuillez réessayer.",
"link": "Mot de passe oublié ?"
},
"resetPassword": {
"title": "Réinitialiser le mot de passe",
"newPassword": "Nouveau mot de passe",
"submit": "Réinitialiser",
"success": "Mot de passe mis à jour avec succès ! Redirection vers la connexion...",
"error": "Échec de la réinitialisation. Le lien a peut-être expiré.",
"tooShort": "Le mot de passe doit contenir au moins 8 caractères.",
"noToken": "Lien invalide. Veuillez en demander un nouveau."
}
},
"home": {
"hero": "Tout ce dont vous avez besoin pour vos fichiers PDF — instantanément",
"heroSub": "Déposez votre fichier ici, nous détecterons automatiquement son type et proposerons les outils adaptés — édition, conversion, compression et plus. Aucune inscription requise.",
@@ -80,6 +99,17 @@
"description": "Convertissez instantanément des images entre les formats JPG, PNG et WebP.",
"shortDesc": "Convertir des images"
},
"imageResize": {
"title": "Redimensionner l'image",
"description": "Redimensionnez vos images aux dimensions exactes tout en préservant la qualité.",
"shortDesc": "Redimensionner",
"dimensions": "Dimensions cibles",
"width": "Largeur (px)",
"height": "Hauteur (px)",
"quality": "Qualité",
"lockAspect": "Verrouiller le rapport d'aspect",
"aspectHint": "Entrez une dimension — l'autre sera calculée automatiquement pour préserver le rapport d'aspect."
},
"videoToGif": {
"title": "Vidéo en GIF",
"description": "Créez des GIFs animés à partir de clips vidéo. Personnalisez le temps de début, la durée et la qualité.",

View File

@@ -633,6 +633,14 @@ export default function AccountPage() {
<button type="submit" className="btn-primary w-full" disabled={authLoading}>
{mode === 'login' ? t('account.submitLogin') : t('account.submitRegister')}
</button>
{mode === 'login' && (
<p className="text-center text-sm">
<a href="/forgot-password" className="text-primary-600 hover:underline dark:text-primary-400">
{t('auth.forgotPassword.link')}
</a>
</p>
)}
</form>
</div>
</section>

View File

@@ -0,0 +1,95 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { Mail } from 'lucide-react';
const API_BASE = import.meta.env.VITE_API_URL || '';
export default function ForgotPasswordPage() {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email }),
});
if (!res.ok) throw new Error('Request failed');
setSubmitted(true);
} catch {
setError(t('auth.forgotPassword.error'));
} finally {
setLoading(false);
}
};
return (
<>
<Helmet>
<title>{t('auth.forgotPassword.title')} {t('common.appName')}</title>
</Helmet>
<div className="mx-auto max-w-md">
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-100 dark:bg-primary-900/30">
<Mail className="h-8 w-8 text-primary-600 dark:text-primary-400" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{t('auth.forgotPassword.title')}
</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">
{t('auth.forgotPassword.subtitle')}
</p>
</div>
{submitted ? (
<div className="rounded-2xl bg-green-50 p-6 text-center ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-800">
<p className="text-sm text-green-700 dark:text-green-400">
{t('auth.forgotPassword.sent')}
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t('common.email')}
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('account.emailPlaceholder')}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
/>
</div>
{error && (
<div className="rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full disabled:opacity-50"
>
{loading ? t('common.loading') : t('auth.forgotPassword.submit')}
</button>
</form>
)}
</div>
</>
);
}

View File

@@ -19,6 +19,7 @@ import {
ListOrdered,
PenLine,
GitBranch,
Scaling,
} from 'lucide-react';
import ToolCard from '@/components/shared/ToolCard';
import HeroUploadZone from '@/components/shared/HeroUploadZone';
@@ -50,6 +51,7 @@ const pdfTools: ToolInfo[] = [
const otherTools: ToolInfo[] = [
{ key: 'imageConvert', path: '/tools/image-converter', icon: <ImageIcon className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' },
{ key: 'imageResize', path: '/tools/image-resize', icon: <Scaling className="h-6 w-6 text-teal-600" />, bgColor: 'bg-teal-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' },

View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
import { KeyRound } from 'lucide-react';
const API_BASE = import.meta.env.VITE_API_URL || '';
export default function ResetPasswordPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') || '';
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password.length < 8) {
setError(t('auth.resetPassword.tooShort'));
return;
}
if (password !== confirm) {
setError(t('account.passwordMismatch'));
return;
}
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Reset failed');
setSuccess(true);
setTimeout(() => navigate('/account'), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : t('auth.resetPassword.error'));
} finally {
setLoading(false);
}
};
if (!token) {
return (
<div className="mx-auto max-w-md text-center">
<p className="text-slate-500 dark:text-slate-400">{t('auth.resetPassword.noToken')}</p>
</div>
);
}
return (
<>
<Helmet>
<title>{t('auth.resetPassword.title')} {t('common.appName')}</title>
</Helmet>
<div className="mx-auto max-w-md">
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-100 dark:bg-primary-900/30">
<KeyRound className="h-8 w-8 text-primary-600 dark:text-primary-400" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{t('auth.resetPassword.title')}
</h1>
</div>
{success ? (
<div className="rounded-2xl bg-green-50 p-6 text-center ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-800">
<p className="text-sm text-green-700 dark:text-green-400">
{t('auth.resetPassword.success')}
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t('auth.resetPassword.newPassword')}
</label>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t('account.confirmPassword')}
</label>
<input
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
/>
</div>
{error && (
<div className="rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full disabled:opacity-50"
>
{loading ? t('common.loading') : t('auth.resetPassword.submit')}
</button>
</form>
)}
</div>
</>
);
}

View File

@@ -15,6 +15,7 @@ import {
Film,
PenLine,
GitBranch,
Scaling,
} from 'lucide-react';
import type { ComponentType, SVGProps } from 'react';
@@ -50,6 +51,7 @@ const pdfTools: ToolOption[] = [
/** Image tools available when an image is uploaded */
const imageTools: ToolOption[] = [
{ key: 'imageConvert', path: '/tools/image-converter', icon: ImageIcon, bgColor: 'bg-purple-100 dark:bg-purple-900/30', iconColor: 'text-purple-600 dark:text-purple-400' },
{ key: 'imageResize', path: '/tools/image-resize', icon: Scaling, bgColor: 'bg-teal-100 dark:bg-teal-900/30', iconColor: 'text-teal-600 dark:text-teal-400' },
{ key: 'imagesToPdf', path: '/tools/images-to-pdf', icon: FileImage, bgColor: 'bg-lime-100 dark:bg-lime-900/30', iconColor: 'text-lime-600 dark:text-lime-400' },
];