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