ميزة: تحديث صفحات الخصوصية والشروط مع تاريخ آخر تحديث ثابت وفترة احتفاظ ديناميكية بالملفات
ميزة: إضافة خدمة تحليلات لتكامل Google Analytics اختبار: تحديث اختبارات خدمة واجهة برمجة التطبيقات (API) لتعكس تغييرات نقاط النهاية إصلاح: تعديل خدمة واجهة برمجة التطبيقات (API) لدعم تحميل ملفات متعددة ومصادقة المستخدم ميزة: تطبيق مخزن مصادقة باستخدام Zustand لإدارة المستخدمين إصلاح: تحسين إعدادات Nginx لتعزيز الأمان ودعم التحليلات
This commit is contained in:
6
frontend/.env.example
Normal file
6
frontend/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
VITE_ADSENSE_CLIENT_ID=ca-pub-XXXXXXXXXXXXXXXX
|
||||
VITE_ADSENSE_SLOT_HOME_TOP=1234567890
|
||||
VITE_ADSENSE_SLOT_HOME_BOTTOM=1234567891
|
||||
VITE_ADSENSE_SLOT_TOP_BANNER=1234567892
|
||||
VITE_ADSENSE_SLOT_BOTTOM_BANNER=1234567893
|
||||
@@ -10,11 +10,11 @@
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="SaaS-PDF — Free Online File Tools" />
|
||||
<meta property="og:description" content="16+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." />
|
||||
<meta property="og:description" content="18+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." />
|
||||
<meta property="og:site_name" content="SaaS-PDF" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="SaaS-PDF — Free Online File Tools" />
|
||||
<meta name="twitter:description" content="16+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." />
|
||||
<meta name="twitter:description" content="18+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." />
|
||||
<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" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import { useDirection } from '@/hooks/useDirection';
|
||||
import { initAnalytics, trackPageView } from '@/services/analytics';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
// Pages
|
||||
const HomePage = lazy(() => import('@/pages/HomePage'));
|
||||
@@ -10,6 +12,7 @@ const AboutPage = lazy(() => import('@/pages/AboutPage'));
|
||||
const PrivacyPage = lazy(() => import('@/pages/PrivacyPage'));
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'));
|
||||
const TermsPage = lazy(() => import('@/pages/TermsPage'));
|
||||
const AccountPage = lazy(() => import('@/pages/AccountPage'));
|
||||
|
||||
// Tool Pages
|
||||
const PdfToWord = lazy(() => import('@/components/tools/PdfToWord'));
|
||||
@@ -41,6 +44,17 @@ function LoadingFallback() {
|
||||
|
||||
export default function App() {
|
||||
useDirection();
|
||||
const location = useLocation();
|
||||
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||
|
||||
useEffect(() => {
|
||||
initAnalytics();
|
||||
void refreshUser();
|
||||
}, [refreshUser]);
|
||||
|
||||
useEffect(() => {
|
||||
trackPageView(`${location.pathname}${location.search}`);
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50 transition-colors duration-300 dark:bg-slate-950">
|
||||
@@ -52,6 +66,7 @@ export default function App() {
|
||||
{/* Pages */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
interface AdSlotProps {
|
||||
/** AdSense ad slot ID */
|
||||
@@ -21,21 +22,50 @@ export default function AdSlot({
|
||||
responsive = true,
|
||||
className = '',
|
||||
}: AdSlotProps) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const adRef = useRef<HTMLModElement>(null);
|
||||
const isLoaded = useRef(false);
|
||||
const clientId = (import.meta.env.VITE_ADSENSE_CLIENT_ID || '').trim();
|
||||
const slotMap: Record<string, string | undefined> = {
|
||||
'home-top': import.meta.env.VITE_ADSENSE_SLOT_HOME_TOP,
|
||||
'home-bottom': import.meta.env.VITE_ADSENSE_SLOT_HOME_BOTTOM,
|
||||
'top-banner': import.meta.env.VITE_ADSENSE_SLOT_TOP_BANNER,
|
||||
'bottom-banner': import.meta.env.VITE_ADSENSE_SLOT_BOTTOM_BANNER,
|
||||
};
|
||||
const resolvedSlot = /^\d+$/.test(slot) ? slot : slotMap[slot];
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded.current) return;
|
||||
if (isLoaded.current || !clientId || !resolvedSlot) return;
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[data-adsense-client="${clientId}"]`
|
||||
);
|
||||
|
||||
if (!existingScript) {
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${clientId}`;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.setAttribute('data-adsense-client', clientId);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
try {
|
||||
// Push ad to AdSense queue
|
||||
const adsbygoogle = (window as any).adsbygoogle || [];
|
||||
const adsWindow = window as Window & { adsbygoogle?: unknown[] };
|
||||
const adsbygoogle = adsWindow.adsbygoogle || [];
|
||||
adsbygoogle.push({});
|
||||
adsWindow.adsbygoogle = adsbygoogle;
|
||||
isLoaded.current = true;
|
||||
} catch {
|
||||
// AdSense not loaded (e.g., ad blocker)
|
||||
}
|
||||
}, []);
|
||||
}, [clientId, resolvedSlot]);
|
||||
|
||||
if (!clientId || !resolvedSlot) return null;
|
||||
|
||||
// Pro users see no ads
|
||||
if (user?.plan === 'pro') return null;
|
||||
|
||||
return (
|
||||
<div className={`ad-slot ${className}`}>
|
||||
@@ -43,8 +73,8 @@ export default function AdSlot({
|
||||
ref={adRef}
|
||||
className="adsbygoogle"
|
||||
style={{ display: 'block' }}
|
||||
data-ad-client={import.meta.env.VITE_ADSENSE_CLIENT_ID || ''}
|
||||
data-ad-slot={slot}
|
||||
data-ad-client={clientId}
|
||||
data-ad-slot={resolvedSlot}
|
||||
data-ad-format={format}
|
||||
data-full-width-responsive={responsive ? 'true' : 'false'}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileText, Moon, Sun, Menu, X, ChevronDown } from 'lucide-react';
|
||||
// ...existing code...
|
||||
import { FileText, Moon, Sun, Menu, X, ChevronDown, UserRound } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
interface LangOption {
|
||||
code: string;
|
||||
label: string;
|
||||
@@ -40,6 +40,7 @@ function useDarkMode() {
|
||||
export default function Header() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isDark, toggle: toggleDark } = useDarkMode();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const langRef = useRef<HTMLDivElement>(null);
|
||||
@@ -85,10 +86,24 @@ export default function Header() {
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
className="text-sm font-medium text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.account')}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/account"
|
||||
className="hidden max-w-[220px] items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 md:flex dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
<span className="truncate">{user?.email || t('common.account')}</span>
|
||||
</Link>
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
@@ -167,6 +182,13 @@ export default function Header() {
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{user?.email || t('common.account')}
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Download, RotateCcw, Clock } from 'lucide-react';
|
||||
import type { TaskResult } from '@/services/api';
|
||||
import { formatFileSize } from '@/utils/textTools';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
|
||||
interface DownloadButtonProps {
|
||||
/** Task result containing download URL */
|
||||
@@ -61,6 +62,9 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
|
||||
<a
|
||||
href={result.download_url}
|
||||
download={result.filename}
|
||||
onClick={() => {
|
||||
trackEvent('download_clicked', { filename: result.filename || 'unknown' });
|
||||
}}
|
||||
className="btn-success w-full"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -7,6 +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';
|
||||
|
||||
/**
|
||||
* The MIME types we accept on the homepage smart upload zone.
|
||||
@@ -62,11 +63,11 @@ export default function HeroUploadZone() {
|
||||
onDrop,
|
||||
accept: ACCEPTED_TYPES,
|
||||
maxFiles: 1,
|
||||
maxSize: 100 * 1024 * 1024, // 100 MB (matches nginx config)
|
||||
maxSize: TOOL_LIMITS_MB.homepageSmartUpload * 1024 * 1024,
|
||||
onDropRejected: (rejections) => {
|
||||
const rejection = rejections[0];
|
||||
if (rejection?.errors[0]?.code === 'file-too-large') {
|
||||
setError(t('common.maxSize', { size: 100 }));
|
||||
setError(t('common.maxSize', { size: TOOL_LIMITS_MB.homepageSmartUpload }));
|
||||
} else {
|
||||
setError(t('home.unsupportedFile'));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import AdSlot from '@/components/layout/AdSlot';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { uploadFiles } from '@/services/api';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
@@ -61,20 +62,7 @@ export default function ImagesToPdf() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((f) => formData.append('files', f));
|
||||
|
||||
const response = await fetch('/api/pdf-tools/images-to-pdf', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Upload failed.');
|
||||
}
|
||||
|
||||
const data = await uploadFiles('/pdf-tools/images-to-pdf', files, 'files');
|
||||
setTaskId(data.task_id);
|
||||
setPhase('processing');
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { uploadFile, type TaskResponse } from '@/services/api';
|
||||
import { uploadFiles } from '@/services/api';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
@@ -62,20 +62,7 @@ export default function MergePdf() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((f) => formData.append('files', f));
|
||||
|
||||
const response = await fetch('/api/pdf-tools/merge', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Upload failed.');
|
||||
}
|
||||
|
||||
const data = await uploadFiles('/pdf-tools/merge', files, 'files');
|
||||
setTaskId(data.task_id);
|
||||
setPhase('processing');
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,14 +4,6 @@ import { Helmet } from 'react-helmet-async';
|
||||
import {
|
||||
PenLine,
|
||||
Save,
|
||||
Download,
|
||||
Undo2,
|
||||
Redo2,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
RotateCw,
|
||||
FileOutput,
|
||||
PanelLeft,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
Info,
|
||||
@@ -24,6 +16,7 @@ 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';
|
||||
|
||||
export default function PdfEditor() {
|
||||
const { t } = useTranslation();
|
||||
@@ -40,7 +33,7 @@ export default function PdfEditor() {
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
endpoint: '/compress/pdf',
|
||||
maxSizeMB: 200,
|
||||
maxSizeMB: TOOL_LIMITS_MB.pdf,
|
||||
acceptedTypes: ['pdf'],
|
||||
extraData: { quality: 'high' },
|
||||
});
|
||||
@@ -77,16 +70,6 @@ export default function PdfEditor() {
|
||||
url: `${window.location.origin}/tools/pdf-editor`,
|
||||
});
|
||||
|
||||
const toolbarButtons = [
|
||||
{ icon: Undo2, label: t('tools.pdfEditor.undo'), shortcut: 'Ctrl+Z' },
|
||||
{ icon: Redo2, label: t('tools.pdfEditor.redo'), shortcut: 'Ctrl+Y' },
|
||||
{ icon: PlusCircle, label: t('tools.pdfEditor.addPage') },
|
||||
{ icon: Trash2, label: t('tools.pdfEditor.deletePage') },
|
||||
{ icon: RotateCw, label: t('tools.pdfEditor.rotate') },
|
||||
{ icon: FileOutput, label: t('tools.pdfEditor.extractPage') },
|
||||
{ icon: PanelLeft, label: t('tools.pdfEditor.thumbnails') },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -117,7 +100,7 @@ export default function PdfEditor() {
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{ 'application/pdf': ['.pdf'] }}
|
||||
maxSizeMB={200}
|
||||
maxSizeMB={TOOL_LIMITS_MB.pdf}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
@@ -145,28 +128,6 @@ export default function PdfEditor() {
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Toolbar Preview */}
|
||||
<div className="rounded-2xl bg-white p-4 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
||||
{t('tools.pdfEditor.thumbnails')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{toolbarButtons.map((btn) => {
|
||||
const Icon = btn.icon;
|
||||
return (
|
||||
<div
|
||||
key={btn.label}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-slate-50 px-3 py-2 text-xs font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-600"
|
||||
title={btn.shortcut ? `${btn.label} (${btn.shortcut})` : btn.label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{btn.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { startTask, uploadFile } from '@/services/api';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
@@ -65,8 +66,8 @@ export default function PdfFlowchart() {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setError(taskError || t('common.error'));
|
||||
onError: (err) => {
|
||||
setError(err || t('common.error'));
|
||||
setStep(0);
|
||||
setUploading(false);
|
||||
},
|
||||
@@ -86,16 +87,7 @@ export default function PdfFlowchart() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/flowchart/extract', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error || 'Upload failed.');
|
||||
const data = await uploadFile('/flowchart/extract', file);
|
||||
setTaskId(data.task_id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed.');
|
||||
@@ -108,11 +100,7 @@ export default function PdfFlowchart() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/flowchart/extract-sample', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Sample failed.');
|
||||
const data = await startTask('/flowchart/extract-sample');
|
||||
setTaskId(data.task_id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sample failed.');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Send, Bot, User, Sparkles, X, Loader2 } from 'lucide-react';
|
||||
import type { Flowchart, ChatMessage } from './types';
|
||||
import api from '@/services/api';
|
||||
|
||||
interface FlowChatProps {
|
||||
flow: Flowchart;
|
||||
@@ -42,16 +43,12 @@ export default function FlowChat({ flow, onClose, onFlowUpdate }: FlowChatProps)
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/flowchart/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
const res = await api.post('/flowchart/chat', {
|
||||
message: text,
|
||||
flow_id: flow.id,
|
||||
flow_data: flow,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = res.data;
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
|
||||
9
frontend/src/config/toolLimits.ts
Normal file
9
frontend/src/config/toolLimits.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const TOOL_LIMITS_MB = {
|
||||
pdf: 20,
|
||||
word: 15,
|
||||
image: 10,
|
||||
video: 50,
|
||||
homepageSmartUpload: 50,
|
||||
} as const;
|
||||
|
||||
export const FILE_RETENTION_MINUTES = 30;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { uploadFile, type TaskResponse } from '@/services/api';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
endpoint: string;
|
||||
@@ -38,26 +39,45 @@ export function useFileUpload({
|
||||
setError(null);
|
||||
setTaskId(null);
|
||||
setUploadProgress(0);
|
||||
const ext = selectedFile.name.split('.').pop()?.toLowerCase() || 'unknown';
|
||||
const sizeMb = Number((selectedFile.size / (1024 * 1024)).toFixed(2));
|
||||
|
||||
// Client-side size check
|
||||
const maxBytes = maxSizeMB * 1024 * 1024;
|
||||
if (selectedFile.size > maxBytes) {
|
||||
setError(`File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
trackEvent('upload_rejected_client', {
|
||||
endpoint,
|
||||
reason: 'size_limit',
|
||||
file_ext: ext,
|
||||
size_mb: sizeMb,
|
||||
max_size_mb: maxSizeMB,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side type check
|
||||
if (acceptedTypes && acceptedTypes.length > 0) {
|
||||
const ext = selectedFile.name.split('.').pop()?.toLowerCase();
|
||||
if (!ext || !acceptedTypes.includes(ext)) {
|
||||
const selectedExt = selectedFile.name.split('.').pop()?.toLowerCase();
|
||||
if (!selectedExt || !acceptedTypes.includes(selectedExt)) {
|
||||
setError(`Invalid file type. Accepted: ${acceptedTypes.join(', ')}`);
|
||||
trackEvent('upload_rejected_client', {
|
||||
endpoint,
|
||||
reason: 'invalid_type',
|
||||
file_ext: ext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
trackEvent('file_selected', {
|
||||
endpoint,
|
||||
file_ext: ext,
|
||||
size_mb: sizeMb,
|
||||
});
|
||||
},
|
||||
[maxSizeMB, acceptedTypes]
|
||||
[maxSizeMB, acceptedTypes, endpoint]
|
||||
);
|
||||
|
||||
const startUpload = useCallback(async (): Promise<string | null> => {
|
||||
@@ -69,6 +89,7 @@ export function useFileUpload({
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
trackEvent('upload_started', { endpoint });
|
||||
|
||||
try {
|
||||
const response: TaskResponse = await uploadFile(
|
||||
@@ -80,11 +101,13 @@ export function useFileUpload({
|
||||
|
||||
setTaskId(response.task_id);
|
||||
setIsUploading(false);
|
||||
trackEvent('upload_accepted', { endpoint });
|
||||
return response.task_id;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed.';
|
||||
setError(message);
|
||||
setIsUploading(false);
|
||||
trackEvent('upload_failed', { endpoint });
|
||||
return null;
|
||||
}
|
||||
}, [file, endpoint]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getTaskStatus, type TaskStatus, type TaskResult } from '@/services/api';
|
||||
import { trackEvent } from '@/services/analytics';
|
||||
|
||||
interface UseTaskPollingOptions {
|
||||
taskId: string | null;
|
||||
@@ -54,22 +55,26 @@ export function useTaskPolling({
|
||||
|
||||
if (taskResult?.status === 'completed') {
|
||||
setResult(taskResult);
|
||||
trackEvent('task_completed', { task_id: taskId });
|
||||
onComplete?.(taskResult);
|
||||
} else {
|
||||
const errMsg = taskResult?.error || 'Processing failed.';
|
||||
setError(errMsg);
|
||||
trackEvent('task_failed', { task_id: taskId, reason: 'result_failed' });
|
||||
onError?.(errMsg);
|
||||
}
|
||||
} else if (taskStatus.state === 'FAILURE') {
|
||||
stopPolling();
|
||||
const errMsg = taskStatus.error || 'Task failed.';
|
||||
setError(errMsg);
|
||||
trackEvent('task_failed', { task_id: taskId, reason: 'state_failure' });
|
||||
onError?.(errMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
stopPolling();
|
||||
const errMsg = err instanceof Error ? err.message : 'Polling failed.';
|
||||
setError(errMsg);
|
||||
trackEvent('task_failed', { task_id: taskId, reason: 'polling_error' });
|
||||
onError?.(errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"terms": "شروط الاستخدام",
|
||||
"language": "اللغة",
|
||||
"allTools": "كل الأدوات",
|
||||
"account": "الحساب",
|
||||
"signIn": "تسجيل الدخول",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"darkMode": "الوضع الداكن",
|
||||
"lightMode": "الوضع الفاتح"
|
||||
},
|
||||
@@ -30,10 +34,10 @@
|
||||
"videoTools": "أدوات الفيديو",
|
||||
"textTools": "أدوات النصوص",
|
||||
"uploadCta": "اسحب ملفك هنا أو اضغط لاختياره",
|
||||
"uploadOr": "ندعم: PDF, Word, JPG, PNG, WebP, MP4 — الحد الأقصى للحجم: 200 ميجابايت.",
|
||||
"uploadOr": "ندعم: PDF, Word, JPG, PNG, WebP, MP4 — الحد الأقصى للحجم: 50 ميجابايت.",
|
||||
"uploadSubtitle": "نستخرج معاينة سريعة ونعرض الأدوات المناسبة فوراً.",
|
||||
"editNow": "عدّل ملفك الآن",
|
||||
"editNowTooltip": "افتح محرّر الملفات — حرّر النصوص، أضف تعليقات، وغيّر الصفحات",
|
||||
"editNow": "حسّن ملف PDF الآن",
|
||||
"editNowTooltip": "افتح أداة تحسين PDF السريعة لإنشاء نسخة نظيفة قابلة للتنزيل",
|
||||
"suggestedTools": "الأدوات المقترحة لملفك",
|
||||
"suggestedToolsDesc": "بعد رفع الملف سنعرض الأدوات المتوافقة تلقائيًا: تحرير نص، تمييز، دمج/تقسيم، ضغط، تحويل إلى Word/صورة، تحويل فيديو إلى GIF، والمزيد.",
|
||||
"selectTool": "اختر أداة",
|
||||
@@ -203,17 +207,17 @@
|
||||
"topLeft": "أعلى اليسار"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"title": "محرّر PDF متقدّم",
|
||||
"description": "حرِّر نصوص PDF، أضف تعليقات، أعد ترتيب الصفحات وسجّل نسخة نهائية. سريع وبسيط ومباشر في المتصفح.",
|
||||
"shortDesc": "تعديل PDF",
|
||||
"intro": "مرحبا! هنا يمكنك تعديل ملف PDF مباشرةً في المتصفح: إضافة نص، تعليق، تمييز، رسم حر، حذف/إضافة صفحات، وتصدير نسخة جديدة دون المساس بالأصل.",
|
||||
"title": "تحسين PDF السريع",
|
||||
"description": "أنشئ نسخة محسّنة ونظيفة من ملف PDF بضغطة واحدة مع الحفاظ على الملف الأصلي بدون تغيير.",
|
||||
"shortDesc": "تحسين PDF",
|
||||
"intro": "ارفع ملف PDF وأنشئ نسخة محسّنة جاهزة للمشاركة والتنزيل.",
|
||||
"steps": {
|
||||
"step1": "أضف عناصر (نص، تمييز، رسم، ملاحظة) باستخدام شريط الأدوات أعلى الصفحة.",
|
||||
"step2": "اضغط حفظ لحفظ نسخة جديدة من الملف (سيُنشأ إصدار جديد ولا يُستبدل الملف الأصلي).",
|
||||
"step3": "اضغط تنزيل لتحميل النسخة النهائية أو اختر مشاركة لنسخ رابط التحميل."
|
||||
"step1": "ارفع ملف PDF.",
|
||||
"step2": "اضغط تحسين لإنشاء نسخة معالجة جديدة.",
|
||||
"step3": "نزّل الملف الناتج أو شارك رابط التحميل."
|
||||
},
|
||||
"save": "حفظ التعديلات",
|
||||
"saveTooltip": "حفظ نسخة جديدة من الملف",
|
||||
"save": "تحسين وحفظ نسخة",
|
||||
"saveTooltip": "إنشاء نسخة محسّنة من الملف",
|
||||
"downloadFile": "تحميل الملف",
|
||||
"downloadTooltip": "تنزيل PDF النهائي",
|
||||
"undo": "تراجع",
|
||||
@@ -224,7 +228,7 @@
|
||||
"extractPage": "استخراج كملف جديد",
|
||||
"thumbnails": "عرض الصفحات",
|
||||
"share": "مشاركة",
|
||||
"versionNote": "نحفظ نسخة جديدة في كل مرة تحفظ فيها التعديلات — لا نغيّر الملف الأصلي. يمكنك الرجوع إلى الإصدارات السابقة من صفحة الملف. يتم حذف الملفات المؤقتة تلقائيًا بعد 30 دقيقة إن لم تكمل العملية.",
|
||||
"versionNote": "هذه الأداة تركّز حاليًا على تحسين ملف PDF وإخراج نسخة نظيفة. لا يتم تعديل الملف الأصلي أبدًا.",
|
||||
"privacyNote": "ملفاتك محمية — نقوم بفحص الملفات أمنياً قبل المعالجة، ونستخدم اتصالاً مشفّراً (HTTPS). راجع سياسة الخصوصية للحصول على المزيد من التفاصيل.",
|
||||
"preparingPreview": "جاري تجهيز المعاينة…",
|
||||
"preparingPreviewSub": "قد يستغرق الأمر بضع ثوانٍ حسب حجم الملف.",
|
||||
@@ -233,7 +237,7 @@
|
||||
"savedSuccess": "تم حفظ التعديلات بنجاح — يمكنك الآن تنزيل الملف.",
|
||||
"processingFailed": "فشل في معالجة الملف. جرّب إعادة التحميل أو حاول لاحقًا.",
|
||||
"retry": "إعادة المحاولة",
|
||||
"fileTooLarge": "حجم الملف أكبر من المسموح (200MB). قلِّل حجم الملف وحاول مرة أخرى."
|
||||
"fileTooLarge": "حجم الملف أكبر من المسموح (20MB). قلِّل حجم الملف وحاول مرة أخرى."
|
||||
},
|
||||
"pdfFlowchart": {
|
||||
"title": "PDF إلى مخطط انسيابي",
|
||||
@@ -332,6 +336,58 @@
|
||||
"sendMessage": "إرسال"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"metaTitle": "الحساب",
|
||||
"heroTitle": "احتفظ بنشاط ملفاتك داخل مساحة عمل آمنة واحدة",
|
||||
"heroSubtitle": "أنشئ حسابًا مجانيًا للاحتفاظ بآخر التنزيلات، والعودة إلى المهام المكتملة، وبناء سجل فعلي لعمليات ملفاتك.",
|
||||
"benefitsTitle": "لماذا تنشئ حسابًا",
|
||||
"benefit1": "احتفظ بالملفات الناتجة الأخيرة في سجل واحد بدل فقدان الروابط بعد كل جلسة.",
|
||||
"benefit2": "اعرف أي أداة أنتجت كل ملف حتى تصبح العمليات المتكررة أسرع وأقل عرضة للأخطاء.",
|
||||
"benefit3": "جهّز مساحة عملك لحدود الاشتراك المستقبلية، والمعالجة المجمعة، والإعدادات المحفوظة.",
|
||||
"loadFailed": "تعذر تحميل بيانات الحساب. حاول مرة أخرى.",
|
||||
"passwordMismatch": "كلمتا المرور غير متطابقتين.",
|
||||
"signInTitle": "سجّل الدخول إلى مساحة عملك",
|
||||
"registerTitle": "أنشئ مساحة العمل المجانية",
|
||||
"formSubtitle": "استخدم نفس الحساب عبر الجلسات حتى يبقى سجل الملفات الناتجة متاحًا لك.",
|
||||
"createAccount": "إنشاء حساب",
|
||||
"emailPlaceholder": "name@example.com",
|
||||
"passwordPlaceholder": "أدخل كلمة مرور قوية",
|
||||
"confirmPassword": "تأكيد كلمة المرور",
|
||||
"confirmPasswordPlaceholder": "أعد إدخال كلمة المرور",
|
||||
"submitLogin": "تسجيل الدخول",
|
||||
"submitRegister": "إنشاء حساب مجاني",
|
||||
"freePlanBadge": "الخطة المجانية",
|
||||
"proPlanBadge": "خطة برو",
|
||||
"signedInAs": "تم تسجيل الدخول باسم",
|
||||
"currentPlan": "الخطة الحالية",
|
||||
"logoutCta": "تسجيل الخروج",
|
||||
"upgradeNotice": "تواصل معنا للترقية إلى خطة برو للحصول على حدود أعلى وإلغاء الإعلانات ووصول B2B API.",
|
||||
"plans": {
|
||||
"free": "مجاني",
|
||||
"pro": "برو"
|
||||
},
|
||||
"webQuotaTitle": "مهام الويب هذا الشهر",
|
||||
"apiQuotaTitle": "مهام API هذا الشهر",
|
||||
"quotaPeriod": "الفترة",
|
||||
"apiKeysTitle": "مفاتيح API",
|
||||
"apiKeysSubtitle": "أدر مفاتيح B2B API. كل مفتاح يمنحك وصولاً متزامناً بمستوى برو لجميع الأدوات.",
|
||||
"apiKeyNamePlaceholder": "اسم المفتاح (مثال: إنتاج)",
|
||||
"apiKeyCreate": "إنشاء مفتاح",
|
||||
"apiKeyCopyWarning": "انسخ هذا المفتاح الآن — لن يظهر مرة أخرى.",
|
||||
"apiKeysEmpty": "لا توجد مفاتيح بعد. أنشئ مفتاحًا أعلاه.",
|
||||
"apiKeyRevoked": "ملغي",
|
||||
"apiKeyRevoke": "إلغاء المفتاح",
|
||||
"historyTitle": "سجل الملفات الأخير",
|
||||
"historySubtitle": "ستظهر هنا تلقائيًا كل المهام الناجحة أو الفاشلة المرتبطة بحسابك.",
|
||||
"historyLoading": "جارٍ تحميل النشاط الأخير...",
|
||||
"historyEmpty": "لا يوجد سجل ملفات بعد. عالج أي ملف أثناء تسجيل الدخول وسيظهر هنا.",
|
||||
"downloadResult": "تحميل النتيجة",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"originalFile": "الملف الأصلي",
|
||||
"outputFile": "الملف الناتج",
|
||||
"statusCompleted": "مكتمل",
|
||||
"statusFailed": "فشل"
|
||||
},
|
||||
"result": {
|
||||
"conversionComplete": "اكتمل التحويل!",
|
||||
"compressionComplete": "اكتمل الضغط!",
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"terms": "Terms of Service",
|
||||
"language": "Language",
|
||||
"allTools": "All Tools",
|
||||
"account": "Account",
|
||||
"signIn": "Sign In",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode"
|
||||
},
|
||||
@@ -30,10 +34,10 @@
|
||||
"videoTools": "Video Tools",
|
||||
"textTools": "Text Tools",
|
||||
"uploadCta": "Drag your file here or click to browse",
|
||||
"uploadOr": "Supported: PDF, Word, JPG, PNG, WebP, MP4 — Max size: 200 MB.",
|
||||
"uploadOr": "Supported: PDF, Word, JPG, PNG, WebP, MP4 — Max size: 50 MB.",
|
||||
"uploadSubtitle": "We generate a quick preview and instantly show matching tools.",
|
||||
"editNow": "Edit Your File Now",
|
||||
"editNowTooltip": "Open the file editor — edit text, add comments, and modify pages",
|
||||
"editNow": "Optimize PDF Now",
|
||||
"editNowTooltip": "Open quick PDF optimization for a cleaner downloadable copy",
|
||||
"suggestedTools": "Suggested Tools for Your File",
|
||||
"suggestedToolsDesc": "After uploading, we automatically show compatible tools: text editing, highlighting, merge/split, compress, convert to Word/image, video to GIF, and more.",
|
||||
"selectTool": "Choose a Tool",
|
||||
@@ -203,17 +207,17 @@
|
||||
"topLeft": "Top Left"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"title": "Advanced PDF Editor",
|
||||
"description": "Edit PDF text, add comments, reorder pages, and save a final copy. Fast, simple, and right in your browser.",
|
||||
"shortDesc": "Edit PDF",
|
||||
"intro": "Here you can edit your PDF directly in the browser: add text, comments, highlights, freehand drawing, delete/add pages, and export a new copy without altering the original.",
|
||||
"title": "Quick PDF Optimizer",
|
||||
"description": "Create a cleaner, optimized copy of your PDF with one click while keeping the original untouched.",
|
||||
"shortDesc": "Optimize PDF",
|
||||
"intro": "Upload your PDF and generate an optimized copy ready for sharing and download.",
|
||||
"steps": {
|
||||
"step1": "Add elements (text, highlight, drawing, note) using the toolbar at the top.",
|
||||
"step2": "Click Save to save a new copy (a new version is created — the original file is not replaced).",
|
||||
"step3": "Click Download to get the final copy, or choose Share to copy the download link."
|
||||
"step1": "Upload your PDF file.",
|
||||
"step2": "Click optimize to create a fresh processed copy.",
|
||||
"step3": "Download or share the generated file link."
|
||||
},
|
||||
"save": "Save Changes",
|
||||
"saveTooltip": "Save a new copy of the file",
|
||||
"save": "Optimize & Save Copy",
|
||||
"saveTooltip": "Create an optimized copy of the file",
|
||||
"downloadFile": "Download File",
|
||||
"downloadTooltip": "Download the final PDF",
|
||||
"undo": "Undo",
|
||||
@@ -224,7 +228,7 @@
|
||||
"extractPage": "Extract as New File",
|
||||
"thumbnails": "View Pages",
|
||||
"share": "Share",
|
||||
"versionNote": "We save a new copy each time you save changes — the original file is never modified. You can revert to previous versions from the file page. Temporary files are automatically deleted after 30 minutes if the process is not completed.",
|
||||
"versionNote": "This tool currently focuses on PDF optimization and clean output generation. The original file is never modified.",
|
||||
"privacyNote": "Your files are protected — we perform security checks before processing and use encrypted connections (HTTPS). See our Privacy Policy for more details.",
|
||||
"preparingPreview": "Preparing preview…",
|
||||
"preparingPreviewSub": "This may take a few seconds depending on file size.",
|
||||
@@ -233,7 +237,7 @@
|
||||
"savedSuccess": "Changes saved successfully — you can now download the file.",
|
||||
"processingFailed": "Failed to process the file. Try re-uploading or try again later.",
|
||||
"retry": "Retry",
|
||||
"fileTooLarge": "File size exceeds the limit (200MB). Please reduce the file size and try again."
|
||||
"fileTooLarge": "File size exceeds the limit (20MB). Please reduce the file size and try again."
|
||||
},
|
||||
"pdfFlowchart": {
|
||||
"title": "PDF to Flowchart",
|
||||
@@ -332,6 +336,58 @@
|
||||
"sendMessage": "Send"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"metaTitle": "Account",
|
||||
"heroTitle": "Save your file activity in one secure workspace",
|
||||
"heroSubtitle": "Create a free account to keep recent downloads, return to finished tasks, and build a usable history for your document workflow.",
|
||||
"benefitsTitle": "Why create an account",
|
||||
"benefit1": "Keep recent generated files in one timeline instead of losing links after each session.",
|
||||
"benefit2": "See which tool produced each result so repeated work is faster and less error-prone.",
|
||||
"benefit3": "Prepare your workspace for future premium limits, batch tools, and saved settings.",
|
||||
"loadFailed": "We couldn't load your account data. Please try again.",
|
||||
"passwordMismatch": "Passwords do not match.",
|
||||
"signInTitle": "Sign in to your workspace",
|
||||
"registerTitle": "Create your free workspace",
|
||||
"formSubtitle": "Use the same account across sessions to keep your generated file history available.",
|
||||
"createAccount": "Create Account",
|
||||
"emailPlaceholder": "name@example.com",
|
||||
"passwordPlaceholder": "Enter a strong password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"confirmPasswordPlaceholder": "Re-enter your password",
|
||||
"submitLogin": "Sign In",
|
||||
"submitRegister": "Create Free Account",
|
||||
"freePlanBadge": "Free Plan",
|
||||
"proPlanBadge": "Pro Plan",
|
||||
"signedInAs": "Signed in as",
|
||||
"currentPlan": "Current plan",
|
||||
"logoutCta": "Sign Out",
|
||||
"upgradeNotice": "Contact us to upgrade to Pro for higher limits, no ads, and B2B API access.",
|
||||
"plans": {
|
||||
"free": "Free",
|
||||
"pro": "Pro"
|
||||
},
|
||||
"webQuotaTitle": "Web Tasks This Month",
|
||||
"apiQuotaTitle": "API Tasks This Month",
|
||||
"quotaPeriod": "Period",
|
||||
"apiKeysTitle": "API Keys",
|
||||
"apiKeysSubtitle": "Manage your B2B API keys. Each key gives Pro-level async access to all tools.",
|
||||
"apiKeyNamePlaceholder": "Key name (e.g. Production)",
|
||||
"apiKeyCreate": "Create Key",
|
||||
"apiKeyCopyWarning": "Copy this key now — it will never be shown again.",
|
||||
"apiKeysEmpty": "No API keys yet. Create one above.",
|
||||
"apiKeyRevoked": "Revoked",
|
||||
"apiKeyRevoke": "Revoke key",
|
||||
"historyTitle": "Recent file history",
|
||||
"historySubtitle": "Completed and failed tasks tied to your account appear here automatically.",
|
||||
"historyLoading": "Loading recent activity...",
|
||||
"historyEmpty": "No file history yet. Process a file while signed in and it will appear here.",
|
||||
"downloadResult": "Download Result",
|
||||
"createdAt": "Created",
|
||||
"originalFile": "Original file",
|
||||
"outputFile": "Output file",
|
||||
"statusCompleted": "Completed",
|
||||
"statusFailed": "Failed"
|
||||
},
|
||||
"result": {
|
||||
"conversionComplete": "Conversion Complete!",
|
||||
"compressionComplete": "Compression Complete!",
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"terms": "Conditions d'utilisation",
|
||||
"language": "Langue",
|
||||
"allTools": "Tous les outils",
|
||||
"account": "Compte",
|
||||
"signIn": "Se connecter",
|
||||
"email": "E-mail",
|
||||
"password": "Mot de passe",
|
||||
"darkMode": "Mode sombre",
|
||||
"lightMode": "Mode clair"
|
||||
},
|
||||
@@ -30,10 +34,10 @@
|
||||
"videoTools": "Outils vidéo",
|
||||
"textTools": "Outils de texte",
|
||||
"uploadCta": "Glissez votre fichier ici ou cliquez pour parcourir",
|
||||
"uploadOr": "Formats supportés : PDF, Word, JPG, PNG, WebP, MP4 — Taille max : 200 Mo.",
|
||||
"uploadOr": "Formats supportés : PDF, Word, JPG, PNG, WebP, MP4 — Taille max : 50 Mo.",
|
||||
"uploadSubtitle": "Nous générons un aperçu rapide et affichons les outils adaptés instantanément.",
|
||||
"editNow": "Modifier votre fichier maintenant",
|
||||
"editNowTooltip": "Ouvrir l'éditeur de fichiers — modifier le texte, ajouter des commentaires et modifier les pages",
|
||||
"editNow": "Optimiser le PDF maintenant",
|
||||
"editNowTooltip": "Ouvrir l'optimiseur PDF rapide pour générer une copie propre téléchargeable",
|
||||
"suggestedTools": "Outils suggérés pour votre fichier",
|
||||
"suggestedToolsDesc": "Après le téléchargement, nous affichons automatiquement les outils compatibles : édition de texte, surlignage, fusion/division, compression, conversion en Word/image, vidéo en GIF, et plus.",
|
||||
"selectTool": "Choisir un outil",
|
||||
@@ -203,17 +207,17 @@
|
||||
"topLeft": "Haut gauche"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"title": "Éditeur PDF avancé",
|
||||
"description": "Modifiez le texte PDF, ajoutez des commentaires, réorganisez les pages et enregistrez une copie finale. Rapide, simple et directement dans votre navigateur.",
|
||||
"shortDesc": "Modifier PDF",
|
||||
"intro": "Ici vous pouvez modifier votre PDF directement dans le navigateur : ajouter du texte, des commentaires, du surlignage, du dessin libre, supprimer/ajouter des pages, et exporter une nouvelle copie sans altérer l'original.",
|
||||
"title": "Optimiseur PDF rapide",
|
||||
"description": "Créez une copie PDF plus propre et optimisée en un clic, sans modifier le fichier original.",
|
||||
"shortDesc": "Optimiser PDF",
|
||||
"intro": "Téléchargez votre PDF et générez une copie optimisée prête à partager et à télécharger.",
|
||||
"steps": {
|
||||
"step1": "Ajoutez des éléments (texte, surlignage, dessin, note) à l'aide de la barre d'outils en haut.",
|
||||
"step2": "Cliquez sur Enregistrer pour sauvegarder une nouvelle copie (une nouvelle version est créée — le fichier original n'est pas remplacé).",
|
||||
"step3": "Cliquez sur Télécharger pour obtenir la copie finale, ou choisissez Partager pour copier le lien de téléchargement."
|
||||
"step1": "Téléchargez votre fichier PDF.",
|
||||
"step2": "Cliquez sur optimiser pour créer une nouvelle copie traitée.",
|
||||
"step3": "Téléchargez le fichier généré ou partagez son lien."
|
||||
},
|
||||
"save": "Enregistrer les modifications",
|
||||
"saveTooltip": "Enregistrer une nouvelle copie du fichier",
|
||||
"save": "Optimiser et enregistrer",
|
||||
"saveTooltip": "Créer une copie optimisée du fichier",
|
||||
"downloadFile": "Télécharger le fichier",
|
||||
"downloadTooltip": "Télécharger le PDF final",
|
||||
"undo": "Annuler",
|
||||
@@ -224,7 +228,7 @@
|
||||
"extractPage": "Extraire comme nouveau fichier",
|
||||
"thumbnails": "Voir les pages",
|
||||
"share": "Partager",
|
||||
"versionNote": "Nous sauvegardons une nouvelle copie à chaque enregistrement — le fichier original n'est jamais modifié. Vous pouvez revenir aux versions précédentes depuis la page du fichier. Les fichiers temporaires sont automatiquement supprimés après 30 minutes si le processus n'est pas terminé.",
|
||||
"versionNote": "Cet outil se concentre actuellement sur l'optimisation PDF et la génération d'une copie propre. Le fichier original n'est jamais modifié.",
|
||||
"privacyNote": "Vos fichiers sont protégés — nous effectuons des vérifications de sécurité avant le traitement et utilisons des connexions chiffrées (HTTPS). Consultez notre politique de confidentialité pour plus de détails.",
|
||||
"preparingPreview": "Préparation de l'aperçu…",
|
||||
"preparingPreviewSub": "Cela peut prendre quelques secondes selon la taille du fichier.",
|
||||
@@ -233,7 +237,7 @@
|
||||
"savedSuccess": "Modifications enregistrées avec succès — vous pouvez maintenant télécharger le fichier.",
|
||||
"processingFailed": "Échec du traitement du fichier. Essayez de le re-télécharger ou réessayez plus tard.",
|
||||
"retry": "Réessayer",
|
||||
"fileTooLarge": "La taille du fichier dépasse la limite (200 Mo). Veuillez réduire la taille du fichier et réessayer."
|
||||
"fileTooLarge": "La taille du fichier dépasse la limite (20 Mo). Veuillez réduire la taille du fichier et réessayer."
|
||||
},
|
||||
"pdfFlowchart": {
|
||||
"title": "PDF vers Organigramme",
|
||||
@@ -332,6 +336,58 @@
|
||||
"sendMessage": "Envoyer"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"metaTitle": "Compte",
|
||||
"heroTitle": "Conservez l'activité de vos fichiers dans un espace sécurisé",
|
||||
"heroSubtitle": "Créez un compte gratuit pour retrouver vos téléchargements récents, revenir sur les tâches terminées et garder un historique utile de votre flux documentaire.",
|
||||
"benefitsTitle": "Pourquoi créer un compte",
|
||||
"benefit1": "Conservez les fichiers générés récents dans une seule chronologie au lieu de perdre les liens à chaque session.",
|
||||
"benefit2": "Identifiez l'outil qui a produit chaque résultat pour accélérer les tâches répétitives et réduire les erreurs.",
|
||||
"benefit3": "Préparez votre espace pour les futures limites premium, les traitements par lots et les préférences enregistrées.",
|
||||
"loadFailed": "Impossible de charger les données du compte. Veuillez réessayer.",
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas.",
|
||||
"signInTitle": "Connectez-vous à votre espace",
|
||||
"registerTitle": "Créez votre espace gratuit",
|
||||
"formSubtitle": "Utilisez le même compte entre les sessions pour conserver l'historique de vos fichiers générés.",
|
||||
"createAccount": "Créer un compte",
|
||||
"emailPlaceholder": "nom@example.com",
|
||||
"passwordPlaceholder": "Entrez un mot de passe fort",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"confirmPasswordPlaceholder": "Saisissez à nouveau votre mot de passe",
|
||||
"submitLogin": "Se connecter",
|
||||
"submitRegister": "Créer un compte gratuit",
|
||||
"freePlanBadge": "Forfait gratuit",
|
||||
"proPlanBadge": "Forfait Pro",
|
||||
"signedInAs": "Connecté en tant que",
|
||||
"currentPlan": "Forfait actuel",
|
||||
"logoutCta": "Se déconnecter",
|
||||
"upgradeNotice": "Contactez-nous pour passer au forfait Pro : limites plus élevées, sans publicité et accès API B2B.",
|
||||
"plans": {
|
||||
"free": "Gratuit",
|
||||
"pro": "Pro"
|
||||
},
|
||||
"webQuotaTitle": "Tâches web ce mois-ci",
|
||||
"apiQuotaTitle": "Tâches API ce mois-ci",
|
||||
"quotaPeriod": "Période",
|
||||
"apiKeysTitle": "Clés API",
|
||||
"apiKeysSubtitle": "Gérez vos clés API B2B. Chaque clé donne un accès asynchrone Pro à tous les outils.",
|
||||
"apiKeyNamePlaceholder": "Nom de la clé (ex. Production)",
|
||||
"apiKeyCreate": "Créer une clé",
|
||||
"apiKeyCopyWarning": "Copiez cette clé maintenant — elle ne sera plus affichée.",
|
||||
"apiKeysEmpty": "Aucune clé API pour l'instant. Créez-en une ci-dessus.",
|
||||
"apiKeyRevoked": "Révoquée",
|
||||
"apiKeyRevoke": "Révoquer la clé",
|
||||
"historyTitle": "Historique récent des fichiers",
|
||||
"historySubtitle": "Les tâches réussies et échouées liées à votre compte apparaissent ici automatiquement.",
|
||||
"historyLoading": "Chargement de l'activité récente...",
|
||||
"historyEmpty": "Aucun historique pour l'instant. Traitez un fichier en étant connecté et il apparaîtra ici.",
|
||||
"downloadResult": "Télécharger le résultat",
|
||||
"createdAt": "Créé le",
|
||||
"originalFile": "Fichier source",
|
||||
"outputFile": "Fichier de sortie",
|
||||
"statusCompleted": "Terminé",
|
||||
"statusFailed": "Échec"
|
||||
},
|
||||
"result": {
|
||||
"conversionComplete": "Conversion terminée !",
|
||||
"compressionComplete": "Compression terminée !",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,20 +23,21 @@ export default function AboutPage() {
|
||||
<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>Private & Secure</strong> — Files are auto-deleted within {FILE_RETENTION_MINUTES} minutes.</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>
|
||||
<li>PDF conversion tools (PDF↔Word)</li>
|
||||
<li>PDF optimization and utility tools (compress, merge, split, rotate, page numbers)</li>
|
||||
<li>PDF security tools (watermark, protect, unlock)</li>
|
||||
<li>PDF/image conversion tools (PDF→Images, Images→PDF)</li>
|
||||
<li>Image processing tools (convert, resize)</li>
|
||||
<li>Video to GIF tool</li>
|
||||
<li>Text tools (word counter, cleaner)</li>
|
||||
<li>PDF to flowchart extraction tool</li>
|
||||
</ul>
|
||||
|
||||
<h2>Contact</h2>
|
||||
|
||||
643
frontend/src/pages/AccountPage.tsx
Normal file
643
frontend/src/pages/AccountPage.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BadgeCheck,
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
FolderClock,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
UserRound,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getHistory,
|
||||
getUsage,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
revokeApiKey,
|
||||
type HistoryEntry,
|
||||
type UsageSummary,
|
||||
type ApiKey,
|
||||
} from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
type AuthMode = 'login' | 'register';
|
||||
|
||||
const toolKeyMap: Record<string, string> = {
|
||||
'pdf-to-word': 'tools.pdfToWord.title',
|
||||
'word-to-pdf': 'tools.wordToPdf.title',
|
||||
'compress-pdf': 'tools.compressPdf.title',
|
||||
'image-convert': 'tools.imageConvert.title',
|
||||
'image-resize': 'tools.imageConvert.title',
|
||||
'video-to-gif': 'tools.videoToGif.title',
|
||||
'merge-pdf': 'tools.mergePdf.title',
|
||||
'split-pdf': 'tools.splitPdf.title',
|
||||
'rotate-pdf': 'tools.rotatePdf.title',
|
||||
'page-numbers': 'tools.pageNumbers.title',
|
||||
'pdf-to-images': 'tools.pdfToImages.title',
|
||||
'images-to-pdf': 'tools.imagesToPdf.title',
|
||||
'watermark-pdf': 'tools.watermarkPdf.title',
|
||||
'protect-pdf': 'tools.protectPdf.title',
|
||||
'unlock-pdf': 'tools.unlockPdf.title',
|
||||
'pdf-flowchart': 'tools.pdfFlowchart.title',
|
||||
'pdf-flowchart-sample': 'tools.pdfFlowchart.title',
|
||||
};
|
||||
|
||||
function formatHistoryTool(tool: string, t: (key: string) => string) {
|
||||
const translationKey = toolKeyMap[tool];
|
||||
return translationKey ? t(translationKey) : tool;
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const authLoading = useAuthStore((state) => state.isLoading);
|
||||
const initialized = useAuthStore((state) => state.initialized);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const register = useAuthStore((state) => state.register);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const [mode, setMode] = useState<AuthMode>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [historyItems, setHistoryItems] = useState<HistoryEntry[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
|
||||
// Usage summary state
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||
|
||||
// API Keys state (pro only)
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [apiKeysLoading, setApiKeysLoading] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyCreating, setNewKeyCreating] = useState(false);
|
||||
const [newKeyError, setNewKeyError] = useState<string | null>(null);
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
|
||||
const dateFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setHistoryItems([]);
|
||||
setHistoryError(null);
|
||||
setUsage(null);
|
||||
setApiKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadHistory = async () => {
|
||||
setHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const items = await getHistory();
|
||||
setHistoryItems(items);
|
||||
} catch (error) {
|
||||
setHistoryError(error instanceof Error ? error.message : t('account.loadFailed'));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsage = async () => {
|
||||
try {
|
||||
const data = await getUsage();
|
||||
setUsage(data);
|
||||
} catch {
|
||||
// non-critical, ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
if (user.plan !== 'pro') return;
|
||||
setApiKeysLoading(true);
|
||||
try {
|
||||
const keys = await getApiKeys();
|
||||
setApiKeys(keys);
|
||||
} catch {
|
||||
// non-critical
|
||||
} finally {
|
||||
setApiKeysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadHistory();
|
||||
void loadUsage();
|
||||
void loadApiKeys();
|
||||
}, [t, user]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitError(null);
|
||||
|
||||
if (mode === 'register' && password !== confirmPassword) {
|
||||
setSubmitError(t('account.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(email, password);
|
||||
} else {
|
||||
await register(email, password);
|
||||
}
|
||||
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : t('account.loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await logout();
|
||||
setHistoryItems([]);
|
||||
setUsage(null);
|
||||
setApiKeys([]);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : t('account.loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateApiKey = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setNewKeyError(null);
|
||||
const name = newKeyName.trim();
|
||||
if (!name) return;
|
||||
setNewKeyCreating(true);
|
||||
try {
|
||||
const key = await createApiKey(name);
|
||||
setApiKeys((prev) => [key, ...prev]);
|
||||
setRevealedKey(key.raw_key ?? null);
|
||||
setNewKeyName('');
|
||||
} catch (error) {
|
||||
setNewKeyError(error instanceof Error ? error.message : t('account.loadFailed'));
|
||||
} finally {
|
||||
setNewKeyCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeApiKey = async (keyId: number) => {
|
||||
try {
|
||||
await revokeApiKey(keyId);
|
||||
setApiKeys((prev) =>
|
||||
prev.map((k) =>
|
||||
k.id === keyId ? { ...k, revoked_at: new Date().toISOString() } : k
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyKey = async () => {
|
||||
if (!revealedKey) return;
|
||||
await navigator.clipboard.writeText(revealedKey);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('account.metaTitle')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('account.heroSubtitle')} />
|
||||
</Helmet>
|
||||
|
||||
{!initialized && authLoading ? (
|
||||
<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 dark:border-primary-800 dark:border-t-primary-400" />
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="space-y-8">
|
||||
<section className="overflow-hidden rounded-[2rem] bg-gradient-to-br from-amber-100 via-orange-50 to-white p-8 shadow-sm ring-1 ring-amber-200 dark:from-amber-950/60 dark:via-slate-900 dark:to-slate-950 dark:ring-amber-900/50">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/80 px-4 py-2 text-sm font-semibold text-amber-900 ring-1 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-700/40">
|
||||
{user.plan === 'pro' ? <Zap className="h-4 w-4" /> : <BadgeCheck className="h-4 w-4" />}
|
||||
{user.plan === 'pro' ? t('account.proPlanBadge') : t('account.freePlanBadge')}
|
||||
</div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||
{t('account.heroTitle')}
|
||||
</h1>
|
||||
<p className="max-w-xl text-base leading-7 text-slate-600 dark:text-slate-300">
|
||||
{t('account.heroSubtitle')}
|
||||
</p>
|
||||
{user.plan === 'free' && (
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
{t('account.upgradeNotice')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] bg-white/90 p-5 shadow-sm ring-1 ring-slate-200 dark:bg-slate-900/90 dark:ring-slate-800">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-slate-800 dark:text-slate-100">
|
||||
<UserRound className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
<span className="text-sm font-medium">{t('account.signedInAs')}</span>
|
||||
</div>
|
||||
<p className="max-w-xs break-all text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>
|
||||
{t('account.currentPlan')}: {user.plan === 'pro' ? t('account.plans.pro') : t('account.plans.free')}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={handleLogout} className="btn-secondary w-full">
|
||||
<LogOut className="h-4 w-4" />
|
||||
{t('account.logoutCta')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Usage / Quota Cards */}
|
||||
{usage && (
|
||||
<section className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="card rounded-[1.5rem] p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">
|
||||
{t('account.webQuotaTitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{usage.web_quota.used}
|
||||
<span className="text-base font-normal text-slate-400"> / {usage.web_quota.limit ?? '∞'}</span>
|
||||
</p>
|
||||
{usage.web_quota.limit != null && (
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary-500 transition-all"
|
||||
style={{ width: `${Math.min(100, (usage.web_quota.used / usage.web_quota.limit) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-slate-400">{t('account.quotaPeriod')}: {usage.period_month}</p>
|
||||
</div>
|
||||
{usage.api_quota.limit != null && (
|
||||
<div className="card rounded-[1.5rem] p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">
|
||||
{t('account.apiQuotaTitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{usage.api_quota.used}
|
||||
<span className="text-base font-normal text-slate-400"> / {usage.api_quota.limit}</span>
|
||||
</p>
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${Math.min(100, (usage.api_quota.used / usage.api_quota.limit) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-400">{t('account.quotaPeriod')}: {usage.period_month}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* API Key Management — Pro only */}
|
||||
{user.plan === 'pro' && (
|
||||
<section className="card rounded-[2rem] p-0">
|
||||
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('account.apiKeysTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('account.apiKeysSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
{/* Create key form */}
|
||||
<form onSubmit={handleCreateApiKey} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder={t('account.apiKeyNamePlaceholder')}
|
||||
maxLength={100}
|
||||
className="input flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={newKeyCreating || !newKeyName.trim()}>
|
||||
{newKeyCreating ? '…' : t('account.apiKeyCreate')}
|
||||
</button>
|
||||
</form>
|
||||
{newKeyError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{newKeyError}</p>
|
||||
)}
|
||||
{/* Revealed key — shown once after creation */}
|
||||
{revealedKey && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 dark:border-emerald-800/60 dark:bg-emerald-950/30">
|
||||
<code className="flex-1 break-all font-mono text-xs text-emerald-800 dark:text-emerald-200">
|
||||
{revealedKey}
|
||||
</code>
|
||||
<button type="button" onClick={handleCopyKey} className="shrink-0 text-emerald-700 dark:text-emerald-300">
|
||||
{copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
<button type="button" onClick={() => setRevealedKey(null)} className="shrink-0 text-slate-400 hover:text-slate-600">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{revealedKey && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{t('account.apiKeyCopyWarning')}</p>
|
||||
)}
|
||||
{/* Key list */}
|
||||
{apiKeysLoading ? (
|
||||
<p className="text-sm text-slate-500">{t('account.historyLoading')}</p>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t('account.apiKeysEmpty')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{apiKeys.map((key) => (
|
||||
<li
|
||||
key={key.id}
|
||||
className={`flex items-center justify-between rounded-xl border px-4 py-3 ${
|
||||
key.revoked_at
|
||||
? 'border-slate-200 bg-slate-50 opacity-50 dark:border-slate-700 dark:bg-slate-900/40'
|
||||
: 'border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900/70'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{key.name}</p>
|
||||
<p className="font-mono text-xs text-slate-400">{key.key_prefix}…</p>
|
||||
{key.revoked_at && (
|
||||
<p className="text-xs text-red-500">{t('account.apiKeyRevoked')}</p>
|
||||
)}
|
||||
</div>
|
||||
{!key.revoked_at && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRevokeApiKey(key.id)}
|
||||
className="ml-4 text-slate-400 hover:text-red-500 dark:hover:text-red-400"
|
||||
title={t('account.apiKeyRevoke')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card rounded-[2rem] p-0">
|
||||
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderClock className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('account.historyTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('account.historySubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
{historyLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t('account.historyLoading')}</p>
|
||||
) : historyError ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
|
||||
{historyError}
|
||||
</div>
|
||||
) : historyItems.length === 0 ? (
|
||||
<div className="rounded-[1.5rem] border border-dashed border-slate-300 bg-slate-50 px-6 py-10 text-center dark:border-slate-700 dark:bg-slate-900/60">
|
||||
<p className="text-base font-medium text-slate-700 dark:text-slate-200">{t('account.historyEmpty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
historyItems.map((item) => {
|
||||
const metadataError =
|
||||
typeof item.metadata?.error === 'string' ? item.metadata.error : null;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-700 dark:bg-slate-900/70"
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500">
|
||||
{formatHistoryTool(item.tool, t)}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{item.output_filename || item.original_filename || formatHistoryTool(item.tool, t)}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('account.createdAt')}: {dateFormatter.format(new Date(item.created_at))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
item.status === 'completed'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{item.status === 'completed'
|
||||
? t('account.statusCompleted')
|
||||
: t('account.statusFailed')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 text-sm text-slate-600 dark:text-slate-300 sm:grid-cols-2">
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3 dark:bg-slate-800/80">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
||||
{t('account.originalFile')}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-medium text-slate-800 dark:text-slate-100">
|
||||
{item.original_filename || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3 dark:bg-slate-800/80">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
||||
{t('account.outputFile')}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-medium text-slate-800 dark:text-slate-100">
|
||||
{item.output_filename || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metadataError ? (
|
||||
<p className="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{metadataError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{item.download_url && item.status === 'completed' ? (
|
||||
<a href={item.download_url} className="btn-primary mt-4 inline-flex">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('account.downloadResult')}
|
||||
</a>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="overflow-hidden rounded-[2rem] bg-gradient-to-br from-cyan-100 via-white to-amber-50 p-8 shadow-sm ring-1 ring-cyan-200 dark:from-cyan-950/50 dark:via-slate-950 dark:to-amber-950/30 dark:ring-cyan-900/40">
|
||||
<div className="max-w-xl space-y-5">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/80 px-4 py-2 text-sm font-semibold text-cyan-900 ring-1 ring-cyan-200 dark:bg-cyan-400/10 dark:text-cyan-200 dark:ring-cyan-700/40">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{t('account.benefitsTitle')}
|
||||
</div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||
{t('account.heroTitle')}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-slate-600 dark:text-slate-300">
|
||||
{t('account.heroSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-4">
|
||||
{[t('account.benefit1'), t('account.benefit2'), t('account.benefit3')].map((benefit) => (
|
||||
<div
|
||||
key={benefit}
|
||||
className="flex items-start gap-3 rounded-[1.25rem] bg-white/80 px-4 py-4 shadow-sm ring-1 ring-white dark:bg-slate-900/80 dark:ring-slate-800"
|
||||
>
|
||||
<KeyRound className="mt-0.5 h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-sm font-medium leading-6 text-slate-700 dark:text-slate-200">{benefit}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-[2rem] bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-800">
|
||||
<div className="grid grid-cols-2 border-b border-slate-200 dark:border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('login');
|
||||
setSubmitError(null);
|
||||
}}
|
||||
className={`px-5 py-4 text-sm font-semibold transition-colors ${
|
||||
mode === 'login'
|
||||
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
|
||||
: 'text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70'
|
||||
}`}
|
||||
>
|
||||
{t('common.signIn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('register');
|
||||
setSubmitError(null);
|
||||
}}
|
||||
className={`px-5 py-4 text-sm font-semibold transition-colors ${
|
||||
mode === 'register'
|
||||
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
|
||||
: 'text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70'
|
||||
}`}
|
||||
>
|
||||
{t('account.createAccount')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{mode === 'login' ? t('account.signInTitle') : t('account.registerTitle')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('account.formSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('common.email')}
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder={t('account.emailPlaceholder')}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('common.password')}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={t('account.passwordPlaceholder')}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{mode === 'register' ? (
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t('account.confirmPassword')}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
placeholder={t('account.confirmPasswordPlaceholder')}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{submitError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={authLoading}>
|
||||
{mode === 'login' ? t('account.submitLogin') : t('account.submitRegister')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
||||
|
||||
const LAST_UPDATED = '2026-03-06';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,7 +16,7 @@ export default function PrivacyPage() {
|
||||
|
||||
<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>
|
||||
<p><em>Last updated: {LAST_UPDATED}</em></p>
|
||||
|
||||
<h2>1. Data Collection</h2>
|
||||
<p>
|
||||
@@ -24,7 +27,7 @@ export default function PrivacyPage() {
|
||||
<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>All uploaded and output files are <strong>automatically deleted within {FILE_RETENTION_MINUTES} minutes</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>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
||||
|
||||
const LAST_UPDATED = '2026-03-06';
|
||||
|
||||
export default function TermsPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,7 +16,7 @@ export default function TermsPage() {
|
||||
|
||||
<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>
|
||||
<p><em>Last updated: {LAST_UPDATED}</em></p>
|
||||
|
||||
<h2>1. Acceptance of Terms</h2>
|
||||
<p>
|
||||
@@ -37,7 +40,7 @@ export default function TermsPage() {
|
||||
|
||||
<h2>4. File Handling</h2>
|
||||
<ul>
|
||||
<li>All uploaded and processed files are automatically deleted within 2 hours.</li>
|
||||
<li>All uploaded and processed files are automatically deleted within {FILE_RETENTION_MINUTES} minutes.</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>
|
||||
|
||||
67
frontend/src/services/analytics.ts
Normal file
67
frontend/src/services/analytics.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
type AnalyticsValue = string | number | boolean | undefined;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer: unknown[];
|
||||
gtag?: (...args: unknown[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const GA_MEASUREMENT_ID = (import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim();
|
||||
let initialized = false;
|
||||
|
||||
function ensureGtagShim() {
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.gtag =
|
||||
window.gtag ||
|
||||
function gtag(...args: unknown[]) {
|
||||
window.dataLayer.push(args);
|
||||
};
|
||||
}
|
||||
|
||||
function loadGaScript() {
|
||||
if (!GA_MEASUREMENT_ID) return;
|
||||
|
||||
const existing = document.querySelector<HTMLScriptElement>(
|
||||
`script[data-ga4-id="${GA_MEASUREMENT_ID}"]`
|
||||
);
|
||||
if (existing) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
|
||||
script.setAttribute('data-ga4-id', GA_MEASUREMENT_ID);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
export function initAnalytics() {
|
||||
if (initialized || !GA_MEASUREMENT_ID || typeof window === 'undefined') return;
|
||||
|
||||
ensureGtagShim();
|
||||
loadGaScript();
|
||||
window.gtag?.('js', new Date());
|
||||
window.gtag?.('config', GA_MEASUREMENT_ID, { send_page_view: false });
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function trackPageView(path: string) {
|
||||
if (!initialized || !window.gtag) return;
|
||||
|
||||
window.gtag('event', 'page_view', {
|
||||
page_path: path,
|
||||
page_location: `${window.location.origin}${path}`,
|
||||
page_title: document.title,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackEvent(
|
||||
eventName: string,
|
||||
params: Record<string, AnalyticsValue> = {}
|
||||
) {
|
||||
if (!initialized || !window.gtag) return;
|
||||
window.gtag('event', eventName, params);
|
||||
}
|
||||
|
||||
export function analyticsEnabled() {
|
||||
return Boolean(GA_MEASUREMENT_ID);
|
||||
}
|
||||
@@ -103,14 +103,13 @@ describe('API Service — Endpoint Format Tests', () => {
|
||||
// PDF Tools endpoints
|
||||
// ----------------------------------------------------------
|
||||
describe('PDF Tools API', () => {
|
||||
it('Merge: should POST multiple files to /api/pdf-tools/merge', () => {
|
||||
// MergePdf.tsx uses fetch('/api/pdf-tools/merge') directly, not api.post
|
||||
it('Merge: should POST multiple files to /pdf-tools/merge', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('files', new Blob(['%PDF-1.4']), 'a.pdf');
|
||||
formData.append('files', new Blob(['%PDF-1.4']), 'b.pdf');
|
||||
const url = '/api/pdf-tools/merge';
|
||||
const url = '/pdf-tools/merge';
|
||||
|
||||
expect(url).toBe('/api/pdf-tools/merge');
|
||||
expect(url).toBe('/pdf-tools/merge');
|
||||
expect(formData.getAll('files').length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -159,14 +158,13 @@ describe('API Service — Endpoint Format Tests', () => {
|
||||
expect(formData.get('format')).toBe('png');
|
||||
});
|
||||
|
||||
it('Images to PDF: should POST multiple files to /api/pdf-tools/images-to-pdf', () => {
|
||||
// ImagesToPdf.tsx uses fetch('/api/pdf-tools/images-to-pdf') directly
|
||||
it('Images to PDF: should POST multiple files to /pdf-tools/images-to-pdf', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('files', new Blob(['\x89PNG']), 'img1.png');
|
||||
formData.append('files', new Blob(['\x89PNG']), 'img2.png');
|
||||
const url = '/api/pdf-tools/images-to-pdf';
|
||||
const url = '/pdf-tools/images-to-pdf';
|
||||
|
||||
expect(url).toBe('/api/pdf-tools/images-to-pdf');
|
||||
expect(url).toBe('/pdf-tools/images-to-pdf');
|
||||
expect(formData.getAll('files').length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -264,9 +262,8 @@ describe('Frontend Tool → Backend Endpoint Mapping', () => {
|
||||
AddPageNumbers: { method: 'POST', endpoint: '/pdf-tools/page-numbers', fieldName: 'file' },
|
||||
PdfToImages: { method: 'POST', endpoint: '/pdf-tools/pdf-to-images', fieldName: 'file' },
|
||||
VideoToGif: { method: 'POST', endpoint: '/video/to-gif', fieldName: 'file' },
|
||||
// Multi-file tools use fetch() directly with full path:
|
||||
MergePdf: { method: 'POST', endpoint: '/api/pdf-tools/merge', fieldName: 'files' },
|
||||
ImagesToPdf: { method: 'POST', endpoint: '/api/pdf-tools/images-to-pdf', fieldName: 'files' },
|
||||
MergePdf: { method: 'POST', endpoint: '/pdf-tools/merge', fieldName: 'files' },
|
||||
ImagesToPdf: { method: 'POST', endpoint: '/pdf-tools/images-to-pdf', fieldName: 'files' },
|
||||
};
|
||||
|
||||
Object.entries(toolEndpointMap).forEach(([tool, config]) => {
|
||||
@@ -276,4 +273,4 @@ describe('Frontend Tool → Backend Endpoint Mapping', () => {
|
||||
expect(config.fieldName).toMatch(/^(file|files)$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import axios from 'axios';
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 120000, // 2 minute timeout for file processing
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
@@ -77,6 +78,38 @@ export interface TaskResult {
|
||||
total_pages?: number;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
plan: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
message: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
interface AuthSessionResponse {
|
||||
authenticated: boolean;
|
||||
user: AuthUser | null;
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
items: HistoryEntry[];
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: number;
|
||||
tool: string;
|
||||
original_filename: string | null;
|
||||
output_filename: string | null;
|
||||
status: 'completed' | 'failed' | string;
|
||||
download_url: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file and start a processing task.
|
||||
*/
|
||||
@@ -108,6 +141,87 @@ export async function uploadFile(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files and start a processing task.
|
||||
*/
|
||||
export async function uploadFiles(
|
||||
endpoint: string,
|
||||
files: File[],
|
||||
fileField = 'files',
|
||||
extraData?: Record<string, string>,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<TaskResponse> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append(fileField, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a task endpoint that does not require file upload.
|
||||
*/
|
||||
export async function startTask(endpoint: string): Promise<TaskResponse> {
|
||||
const response = await api.post<TaskResponse>(endpoint);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new account and return the authenticated user.
|
||||
*/
|
||||
export async function registerUser(email: string, password: string): Promise<AuthUser> {
|
||||
const response = await api.post<AuthResponse>('/auth/register', { email, password });
|
||||
return response.data.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in and return the authenticated user.
|
||||
*/
|
||||
export async function loginUser(email: string, password: string): Promise<AuthUser> {
|
||||
const response = await api.post<AuthResponse>('/auth/login', { email, password });
|
||||
return response.data.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current authenticated session.
|
||||
*/
|
||||
export async function logoutUser(): Promise<void> {
|
||||
await api.post('/auth/logout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current authenticated user, if any.
|
||||
*/
|
||||
export async function getCurrentUser(): Promise<AuthUser | null> {
|
||||
const response = await api.get<AuthSessionResponse>('/auth/me');
|
||||
return response.data.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return recent authenticated file history.
|
||||
*/
|
||||
export async function getHistory(limit = 50): Promise<HistoryEntry[]> {
|
||||
const response = await api.get<HistoryResponse>('/history', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll task status.
|
||||
*/
|
||||
@@ -128,4 +242,63 @@ export async function checkHealth(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Account / Usage / API Keys ---
|
||||
|
||||
export interface UsageSummary {
|
||||
plan: string;
|
||||
period_month: string;
|
||||
ads_enabled: boolean;
|
||||
history_limit: number;
|
||||
file_limits_mb: {
|
||||
pdf: number;
|
||||
word: number;
|
||||
image: number;
|
||||
video: number;
|
||||
homepageSmartUpload: number;
|
||||
};
|
||||
web_quota: { used: number; limit: number | null };
|
||||
api_quota: { used: number; limit: number | null };
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
created_at: string;
|
||||
raw_key?: string; // only present on creation
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current user's plan, quota, and file-limit summary.
|
||||
*/
|
||||
export async function getUsage(): Promise<UsageSummary> {
|
||||
const response = await api.get<UsageSummary>('/account/usage');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all API keys for the authenticated pro user.
|
||||
*/
|
||||
export async function getApiKeys(): Promise<ApiKey[]> {
|
||||
const response = await api.get<{ items: ApiKey[] }>('/account/api-keys');
|
||||
return response.data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key with the given name. Returns the key including raw_key once.
|
||||
*/
|
||||
export async function createApiKey(name: string): Promise<ApiKey> {
|
||||
const response = await api.post<ApiKey>('/account/api-keys', { name });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke one API key by id.
|
||||
*/
|
||||
export async function revokeApiKey(keyId: number): Promise<void> {
|
||||
await api.delete(`/account/api-keys/${keyId}`);
|
||||
}
|
||||
|
||||
export default api;
|
||||
|
||||
71
frontend/src/stores/authStore.ts
Normal file
71
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
getCurrentUser,
|
||||
loginUser,
|
||||
logoutUser,
|
||||
registerUser,
|
||||
type AuthUser,
|
||||
} from '@/services/api';
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
initialized: boolean;
|
||||
refreshUser: () => Promise<AuthUser | null>;
|
||||
login: (email: string, password: string) => Promise<AuthUser>;
|
||||
register: (email: string, password: string) => Promise<AuthUser>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
initialized: false,
|
||||
|
||||
refreshUser: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
set({ user, isLoading: false, initialized: true });
|
||||
return user;
|
||||
} catch {
|
||||
set({ user: null, isLoading: false, initialized: true });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const user = await loginUser(email, password);
|
||||
set({ user, isLoading: false, initialized: true });
|
||||
return user;
|
||||
} catch (error) {
|
||||
set({ isLoading: false, initialized: true });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (email: string, password: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const user = await registerUser(email, password);
|
||||
set({ user, isLoading: false, initialized: true });
|
||||
return user;
|
||||
} catch (error) {
|
||||
set({ isLoading: false, initialized: true });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await logoutUser();
|
||||
set({ user: null, isLoading: false, initialized: true });
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user