ميزة: تحديث صفحات الخصوصية والشروط مع تاريخ آخر تحديث ثابت وفترة احتفاظ ديناميكية بالملفات
ميزة: إضافة خدمة تحليلات لتكامل Google Analytics اختبار: تحديث اختبارات خدمة واجهة برمجة التطبيقات (API) لتعكس تغييرات نقاط النهاية إصلاح: تعديل خدمة واجهة برمجة التطبيقات (API) لدعم تحميل ملفات متعددة ومصادقة المستخدم ميزة: تطبيق مخزن مصادقة باستخدام Zustand لإدارة المستخدمين إصلاح: تحسين إعدادات Nginx لتعزيز الأمان ودعم التحليلات
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user