Compare commits
3 Commits
c483e8508b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f63c929f25 | ||
|
|
4e0da90558 | ||
|
|
a539ad43af |
136
.github/skills/code-review/SKILL.md
vendored
Normal file
136
.github/skills/code-review/SKILL.md
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
name: code-review
|
||||||
|
description: 'مراجعة الكود وإصلاح الأخطاء في مشروع SaaS-PDF. استخدم عند: تشخيص مشكلة، مراجعة ملف أو ميزة، إصلاح خطأ، التحقق من الأمان (OWASP)، تغطية الاختبارات، أداء الكود، توافق Frontend/Backend. Use when: code review, bug fix, security audit, performance check, test coverage, logic review.'
|
||||||
|
argument-hint: 'اذكر الملف أو الميزة أو المشكلة المراد مراجعتها'
|
||||||
|
---
|
||||||
|
|
||||||
|
# مراجعة الكود وإصلاح الأخطاء — SaaS-PDF
|
||||||
|
|
||||||
|
## متى تُستخدم هذه المهارة
|
||||||
|
- عند الإبلاغ عن خطأ أو مشكلة في الكود
|
||||||
|
- عند مراجعة ملف أو خدمة أو endpoint محدد
|
||||||
|
- عند إضافة ميزة جديدة والتحقق من جودتها
|
||||||
|
- عند الرغبة في تقرير شامل بالأخطاء والتوصيات
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الخطوات المتبعة
|
||||||
|
|
||||||
|
### 1. فهم المشكلة والسياق
|
||||||
|
- اقرأ المعطيات الأولية: رسالة الخطأ، الملف المعني، السلوك المتوقع مقابل الفعلي.
|
||||||
|
- ابحث في قاعدة الكود بحثاً مستهدفاً (لا تفترض — تحقق).
|
||||||
|
- حدد نطاق التأثير: هل المشكلة في `backend/`، `frontend/`، أم في كليهما؟
|
||||||
|
|
||||||
|
### 2. البحث عن أفضل حل حديث
|
||||||
|
- قارن الحلول المتاحة بناءً على:
|
||||||
|
- التوافق مع الإصدار الحالي من المكتبات (`requirements.txt` / `package.json`)
|
||||||
|
- أفضل الممارسات لعام 2025+
|
||||||
|
- أقل تعقيد وأكبر صيانة
|
||||||
|
- اختر الحل الأكثر مباشرةً دون هندسة مفرطة.
|
||||||
|
|
||||||
|
### 3. قائمة التحقق الشاملة
|
||||||
|
|
||||||
|
#### الأمان (OWASP Top 10)
|
||||||
|
- [ ] لا يوجد SQL Injection أو NoSQL Injection
|
||||||
|
- [ ] لا يوجد XSS أو CSRF غير محمي
|
||||||
|
- [ ] التحقق من صحة المدخلات على الـ backend (لا تثق في الـ frontend فقط)
|
||||||
|
- [ ] لا توجد بيانات حساسة مكشوفة في الاستجابات أو logs
|
||||||
|
- [ ] الجلسات ورموز JWT محمية بشكل صحيح
|
||||||
|
- [ ] لا توجد أذونات مفرطة في المسارات المحمية
|
||||||
|
|
||||||
|
#### معايير الكود
|
||||||
|
- [ ] يتبع الكود أسلوب `backend/` الحالي (Flask Blueprints, Services pattern)
|
||||||
|
- [ ] يتبع الكود أسلوب `frontend/` الحالي (Vite/TypeScript, custom hooks)
|
||||||
|
- [ ] لا توجد وظائف متكررة موجودة مسبقاً في `services/` أو `utils/`
|
||||||
|
- [ ] أسماء المتغيرات والدوال واضحة ومعبّرة
|
||||||
|
|
||||||
|
#### الاختبارات
|
||||||
|
- [ ] يوجد اختبار وحدة يغطي السلوك الجديد أو المُصلَح
|
||||||
|
- [ ] اختبارات backend: `cd backend && python -m pytest tests/ -q`
|
||||||
|
- [ ] اختبارات frontend: `cd frontend && npx vitest run`
|
||||||
|
- [ ] لا يوجد اختبار مكسور قبل أو بعد التغيير
|
||||||
|
|
||||||
|
#### الأداء
|
||||||
|
- [ ] لا توجد استعلامات N+1 أو عمليات تكرارية غير ضرورية
|
||||||
|
- [ ] الملفات الكبيرة تُعالَج بشكل منقسم (streaming/chunked)
|
||||||
|
- [ ] لا يوجد blocking I/O في Celery tasks
|
||||||
|
- [ ] استجابة الـ API ضمن الحدود الطبيعية
|
||||||
|
|
||||||
|
#### منطق الأعمال
|
||||||
|
- [ ] السلوك يتطابق مع المتطلبات الموثقة في `docs/`
|
||||||
|
- [ ] حالات الحافة (edge cases) معالجة (ملف فارغ، مستخدم غير مسجل، إلخ)
|
||||||
|
- [ ] رسائل الخطأ مفيدة للمستخدم وليست كاشفة للنظام
|
||||||
|
|
||||||
|
#### توافق Frontend/Backend
|
||||||
|
- [ ] نقاط النهاية (endpoints) متطابقة بين `routes/` والاستدعاءات في `frontend/src/`
|
||||||
|
- [ ] هياكل البيانات المُرسَلة والمُستقبَلة متوافقة
|
||||||
|
- [ ] رموز HTTP الصحيحة مستخدمة (200, 201, 400, 401, 403, 404, 422, 500)
|
||||||
|
|
||||||
|
### 4. تطبيق الإصلاح
|
||||||
|
- طبّق أصغر تغيير ممكن يحل المشكلة.
|
||||||
|
- لا تعيد هيكلة كود لم يُطلب تغييره.
|
||||||
|
- اختبر التغيير فوراً بعد تطبيقه.
|
||||||
|
|
||||||
|
### 5. التحقق من عمل المشروع بالكامل
|
||||||
|
بعد الإصلاح، شغّل هذه الأوامر:
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && python -m pytest tests/ -q
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npx vitest run
|
||||||
|
|
||||||
|
# Full stack (إن كان Docker متاحاً)
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. إنتاج التقرير
|
||||||
|
أنتج تقريراً يحتوي على:
|
||||||
|
|
||||||
|
```
|
||||||
|
## تقرير مراجعة الكود
|
||||||
|
|
||||||
|
### الملفات المراجعة
|
||||||
|
- [اذكر الملفات]
|
||||||
|
|
||||||
|
### المشاكل المكتشفة
|
||||||
|
| الأولوية | المشكلة | الملف | السطر | الحل المقترح |
|
||||||
|
|----------|---------|-------|-------|--------------|
|
||||||
|
| عالية | ... | ... | ... | ... |
|
||||||
|
|
||||||
|
### الإصلاحات المطبقة
|
||||||
|
- [ما تم إصلاحه بالفعل]
|
||||||
|
|
||||||
|
### توصيات لاحقة
|
||||||
|
- [مشاكل غير حرجة تستحق المعالجة لاحقاً]
|
||||||
|
|
||||||
|
### نتائج الاختبارات
|
||||||
|
- Backend: ✅ / ❌
|
||||||
|
- Frontend: ✅ / ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## هيكل المشروع (مرجع سريع)
|
||||||
|
|
||||||
|
| الطبقة | المسار | التقنية |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| Backend | `backend/app/routes/` | Flask Blueprints |
|
||||||
|
| Services | `backend/app/services/` | Python classes |
|
||||||
|
| Tasks | `backend/app/tasks/` | Celery |
|
||||||
|
| Tests | `backend/tests/` | pytest |
|
||||||
|
| Frontend | `frontend/src/` | Vite + TypeScript + React |
|
||||||
|
| API hooks | `frontend/src/hooks/` | Custom React hooks |
|
||||||
|
|
||||||
|
## أوامر مفيدة
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# تشغيل اختبار واحد
|
||||||
|
cd backend && python -m pytest tests/test_<name>.py -v
|
||||||
|
|
||||||
|
# فحص أخطاء TypeScript
|
||||||
|
cd frontend && npx tsc --noEmit
|
||||||
|
|
||||||
|
# عرض تغطية الاختبارات
|
||||||
|
cd backend && python -m pytest tests/ --cov=app --cov-report=term-missing
|
||||||
|
```
|
||||||
@@ -143,7 +143,7 @@ def pptx_to_pdf_route():
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sign PDF — POST /api/pdf-tools/sign
|
# Sign PDF — POST /api/convert/sign
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@pdf_convert_bp.route("/sign", methods=["POST"])
|
@pdf_convert_bp.route("/sign", methods=["POST"])
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
|
|||||||
@@ -4,11 +4,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="apple-touch-icon" href="/icons/icon-512.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
|
content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
|
||||||
<meta name="application-name" content="Dociva" />
|
<meta name="application-name" content="Dociva" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Dociva" />
|
<meta name="apple-mobile-web-app-title" content="Dociva" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="theme-color" content="#2563eb" />
|
<meta name="theme-color" content="#2563eb" />
|
||||||
<meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
|
<meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
|
||||||
<meta name="msvalidate.01" content="65E1161EF971CA2810FE8EABB5F229B4" />
|
<meta name="msvalidate.01" content="65E1161EF971CA2810FE8EABB5F229B4" />
|
||||||
|
|||||||
4082
frontend/package-lock.json
generated
4082
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,8 @@
|
|||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
"vitest": "^4.0.18"
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"workbox-window": "^7.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
frontend/public/icons/icon-512.svg
Normal file
13
frontend/public/icons/icon-512.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#4F46E5"/>
|
||||||
|
<stop offset="100%" stop-color="#7C3AED"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||||
|
<path d="M160 128h112l80 80v176a16 16 0 0 1-16 16H160a16 16 0 0 1-16-16V144a16 16 0 0 1 16-16z" fill="rgba(255,255,255,0.15)" stroke="#fff" stroke-width="16"/>
|
||||||
|
<path d="M272 128v64a16 16 0 0 0 16 16h64" stroke="#fff" stroke-width="16" fill="none"/>
|
||||||
|
<path d="M192 256h128M192 296h96M192 336h64" stroke="#93C5FD" stroke-width="14" stroke-linecap="round"/>
|
||||||
|
<text x="312" y="432" font-family="Arial,Helvetica,sans-serif" font-weight="700" font-size="128" fill="#E0E7FF" opacity="0.6">D</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 868 B |
16
frontend/public/icons/maskable-512.svg
Normal file
16
frontend/public/icons/maskable-512.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||||
|
<!-- Maskable icon: safe zone is central 80% (409.6px), so content fits within ~51-461 -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#4F46E5"/>
|
||||||
|
<stop offset="100%" stop-color="#7C3AED"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Full bleed background -->
|
||||||
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
|
<!-- Content centered within safe zone -->
|
||||||
|
<path d="M176 144h96l72 72v160a14 14 0 0 1-14 14H176a14 14 0 0 1-14-14V158a14 14 0 0 1 14-14z" fill="rgba(255,255,255,0.15)" stroke="#fff" stroke-width="14"/>
|
||||||
|
<path d="M272 144v56a14 14 0 0 0 14 14h56" stroke="#fff" stroke-width="14" fill="none"/>
|
||||||
|
<path d="M204 264h112M204 300h84M204 336h56" stroke="#93C5FD" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
<text x="296" y="416" font-family="Arial,Helvetica,sans-serif" font-weight="700" font-size="112" fill="#E0E7FF" opacity="0.6">D</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
57
frontend/public/manifest.json
Normal file
57
frontend/public/manifest.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "Dociva — Free Online File Tools",
|
||||||
|
"short_name": "Dociva",
|
||||||
|
"description": "30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required.",
|
||||||
|
"start_url": "/",
|
||||||
|
"id": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#2563eb",
|
||||||
|
"orientation": "any",
|
||||||
|
"scope": "/",
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"dir": "auto",
|
||||||
|
"lang": "en",
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.svg",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/maskable-512.svg",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Compress PDF",
|
||||||
|
"short_name": "Compress",
|
||||||
|
"url": "/compress-pdf",
|
||||||
|
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Merge PDF",
|
||||||
|
"short_name": "Merge",
|
||||||
|
"url": "/merge-pdf",
|
||||||
|
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Convert to PDF",
|
||||||
|
"short_name": "Convert",
|
||||||
|
"url": "/image-to-pdf",
|
||||||
|
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ const SeoRoutePage = lazy(() => import('@/pages/SeoRoutePage'));
|
|||||||
const ComparisonPage = lazy(() => import('@/pages/ComparisonPage'));
|
const ComparisonPage = lazy(() => import('@/pages/ComparisonPage'));
|
||||||
const CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
|
const CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
|
||||||
const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant'));
|
const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant'));
|
||||||
|
const PwaUpdatePrompt = lazy(() => import('@/components/layout/PwaUpdatePrompt'));
|
||||||
|
|
||||||
// Tool components — derived from manifest using React.lazy
|
// Tool components — derived from manifest using React.lazy
|
||||||
const ToolComponents = Object.fromEntries(
|
const ToolComponents = Object.fromEntries(
|
||||||
@@ -169,6 +170,7 @@ export default function App() {
|
|||||||
<SiteAssistant />
|
<SiteAssistant />
|
||||||
</IdleLoad>
|
</IdleLoad>
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
|
<PwaUpdatePrompt />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Toaster
|
<Toaster
|
||||||
position={isRTL ? 'top-left' : 'top-right'}
|
position={isRTL ? 'top-left' : 'top-right'}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function CookieConsent() {
|
|||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={t('cookie.title', 'Cookie Consent')}
|
aria-label={t('cookie.title', 'Cookie Consent')}
|
||||||
className="fixed inset-x-0 bottom-0 z-50 p-4 sm:p-6"
|
className="fixed inset-x-0 bottom-0 z-50 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:p-6 sm:pb-[max(1.5rem,env(safe-area-inset-bottom))]"
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:flex sm:items-start sm:gap-4">
|
<div className="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:flex sm:items-start sm:gap-4">
|
||||||
<div className="mb-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400 sm:mb-0">
|
<div className="mb-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400 sm:mb-0">
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default function Header() {
|
|||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b border-slate-200/70 bg-white/78 backdrop-blur-2xl dark:border-slate-700/60 dark:bg-slate-950/78">
|
<header className="sticky-header-safe sticky top-0 z-50 border-b border-slate-200/70 bg-white/78 backdrop-blur-2xl dark:border-slate-700/60 dark:bg-slate-950/78">
|
||||||
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<Link to="/" className="group flex items-center gap-3">
|
<Link to="/" className="group flex items-center gap-3">
|
||||||
|
|||||||
47
frontend/src/components/layout/PwaUpdatePrompt.tsx
Normal file
47
frontend/src/components/layout/PwaUpdatePrompt.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { RefreshCw, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePwaRegistration } from '../../hooks/usePwaRegistration';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a bottom-right toast when a new service-worker version is available.
|
||||||
|
* The user can choose to reload immediately or dismiss.
|
||||||
|
*/
|
||||||
|
export default function PwaUpdatePrompt() {
|
||||||
|
const { needRefresh, acceptUpdate, dismissUpdate } = usePwaRegistration();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!needRefresh) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="fixed bottom-4 right-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:bottom-6 sm:right-6"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400">
|
||||||
|
<RefreshCw className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
{t('pwa.updateAvailable', 'Update available')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{t('pwa.updateDescription', 'A new version is ready. Reload to get the latest features.')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={acceptUpdate}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-primary-700 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
{t('pwa.reload', 'Reload')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={dismissUpdate}
|
||||||
|
className="rounded-lg p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -195,7 +195,7 @@ export default function SiteAssistant() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed inset-x-4 bottom-4 z-40 flex justify-end sm:bottom-6 sm:right-6 sm:left-auto">
|
<div className="pointer-events-none fixed inset-x-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-40 flex justify-end sm:bottom-6 sm:right-6 sm:left-auto">
|
||||||
<div className="pointer-events-auto w-full max-w-sm">
|
<div className="pointer-events-auto w-full max-w-sm">
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mb-3 overflow-hidden rounded-[28px] border border-slate-200/80 bg-white/95 shadow-[0_20px_80px_rgba(15,23,42,0.16)] backdrop-blur dark:border-slate-700/80 dark:bg-slate-950/95">
|
<div className="mb-3 overflow-hidden rounded-[28px] border border-slate-200/80 bg-white/95 shadow-[0_20px_80px_rgba(15,23,42,0.16)] backdrop-blur dark:border-slate-700/80 dark:bg-slate-950/95">
|
||||||
@@ -225,7 +225,7 @@ export default function SiteAssistant() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} className="max-h-[26rem] space-y-3 overflow-y-auto px-4 py-4">
|
<div ref={scrollRef} className="max-h-[50dvh] space-y-3 overflow-y-auto overscroll-contain px-4 py-4 sm:max-h-[26rem]">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="rounded-3xl border border-sky-100 bg-sky-50/80 p-4 text-sm text-slate-700 dark:border-sky-900/50 dark:bg-slate-900 dark:text-slate-200">
|
<div className="rounded-3xl border border-sky-100 bg-sky-50/80 p-4 text-sm text-slate-700 dark:border-sky-900/50 dark:bg-slate-900 dark:text-slate-200">
|
||||||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Download className="h-5 w-5" />
|
<Download className="h-5 w-5 shrink-0" />
|
||||||
{t('common.download')} — {result.filename}
|
<span className="truncate">{t('common.download')} — {result.filename}</span>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function ProgressBar({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Main Progress Card */}
|
{/* Main Progress Card */}
|
||||||
<div className="rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
<div className="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200 sm:p-5 dark:bg-slate-800 dark:ring-slate-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" />
|
<Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" />
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function SharePanel({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 w-full max-w-md rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur dark:border-slate-700 dark:bg-slate-900/95">
|
<div className="mt-3 w-full max-w-[calc(100vw-2rem)] rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur sm:max-w-md dark:border-slate-700 dark:bg-slate-900/95">
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-sky-50 via-white to-emerald-50 p-4 dark:from-slate-800 dark:via-slate-900 dark:to-slate-800">
|
<div className="rounded-2xl bg-gradient-to-br from-sky-50 via-white to-emerald-50 p-4 dark:from-slate-800 dark:via-slate-900 dark:to-slate-800">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600 dark:text-sky-300">
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600 dark:text-sky-300">
|
||||||
{variant === 'result' ? t('share.resultLabel') : t('share.toolLabel')}
|
{variant === 'result' ? t('share.resultLabel') : t('share.toolLabel')}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default function ToolSelectorModal({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="tool-selector-title"
|
aria-labelledby="tool-selector-title"
|
||||||
>
|
>
|
||||||
<div className="modal-content flex w-full max-w-lg max-h-[90vh] flex-col rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
<div className="modal-content flex w-full max-w-lg max-h-[90dvh] flex-col rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-5 flex items-start justify-between">
|
<div className="mb-5 flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default function ChatPdf() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={question} onChange={(e) => setQuestion(e.target.value)}
|
value={question} onChange={(e) => setQuestion(e.target.value)}
|
||||||
placeholder={t('tools.chatPdf.questionPlaceholder')}
|
placeholder={t('tools.chatPdf.questionPlaceholder')}
|
||||||
rows={3}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function ImageConverter() {
|
|||||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||||
Convert to:
|
Convert to:
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3">
|
||||||
{formats.map((f) => (
|
{formats.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.value}
|
key={f.value}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export default function ImageResize() {
|
|||||||
{t('tools.imageResize.lockAspect')}
|
{t('tools.imageResize.lockAspect')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
|
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
|
||||||
{t('tools.imageResize.width')}
|
{t('tools.imageResize.width')}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function PdfCompressor() {
|
|||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
||||||
{t('tools.compressPdf.quality', { defaultValue: 'Compression Quality' })}
|
{t('tools.compressPdf.quality', { defaultValue: 'Compression Quality' })}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3">
|
||||||
{qualityOptions.map((opt) => (
|
{qualityOptions.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function SignPdf() {
|
|||||||
fd.append('file', pdfFile);
|
fd.append('file', pdfFile);
|
||||||
fd.append('signature', sigFile);
|
fd.append('signature', sigFile);
|
||||||
fd.append('page', String(page));
|
fd.append('page', String(page));
|
||||||
const res = await api.post<TaskResponse>('/pdf-tools/sign', fd);
|
const res = await api.post<TaskResponse>('/convert/sign', fd);
|
||||||
setTaskId(res.data.task_id);
|
setTaskId(res.data.task_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : t('common.errors.processingFailed');
|
const msg = err instanceof Error ? err.message : t('common.errors.processingFailed');
|
||||||
|
|||||||
51
frontend/src/hooks/usePwaRegistration.ts
Normal file
51
frontend/src/hooks/usePwaRegistration.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight hook that registers the Workbox service-worker generated by
|
||||||
|
* vite-plugin-pwa and exposes an "update available" flag so the UI can
|
||||||
|
* prompt the user to refresh.
|
||||||
|
*/
|
||||||
|
export function usePwaRegistration() {
|
||||||
|
const [needRefresh, setNeedRefresh] = useState(false);
|
||||||
|
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
try {
|
||||||
|
const { registerSW } = await import('virtual:pwa-register');
|
||||||
|
const updateSW = registerSW({
|
||||||
|
immediate: false,
|
||||||
|
onRegisteredSW(_swUrl: string, reg: ServiceWorkerRegistration | undefined) {
|
||||||
|
if (reg) setRegistration(reg);
|
||||||
|
},
|
||||||
|
onNeedRefresh() {
|
||||||
|
setNeedRefresh(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store updateSW so we can call it from acceptUpdate
|
||||||
|
(window as unknown as Record<string, unknown>).__pwaUpdateSW = updateSW;
|
||||||
|
} catch {
|
||||||
|
// SW registration failed (e.g. non-HTTPS in dev) — silently ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
register();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const acceptUpdate = () => {
|
||||||
|
const updateSW = (window as unknown as Record<string, unknown>).__pwaUpdateSW as
|
||||||
|
| ((reloadPage?: boolean) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
if (updateSW) {
|
||||||
|
updateSW(true);
|
||||||
|
}
|
||||||
|
setNeedRefresh(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissUpdate = () => setNeedRefresh(false);
|
||||||
|
|
||||||
|
return { needRefresh, acceptUpdate, dismissUpdate, registration };
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -41,15 +42,15 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-primary-500 dark:hover:bg-primary-600;
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-primary-500 dark:hover:bg-primary-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-sm font-semibold text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300 transition-all hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-600 dark:hover:bg-slate-700;
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-sm font-semibold text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300 transition-all hover:bg-slate-50 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-600 dark:hover:bg-slate-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-emerald-500 dark:hover:bg-emerald-600;
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed dark:bg-emerald-500 dark:hover:bg-emerald-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
|
|
||||||
/* Upload zone styles */
|
/* Upload zone styles */
|
||||||
.upload-zone {
|
.upload-zone {
|
||||||
@apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 p-8 text-center transition-colors cursor-pointer dark:border-slate-600 dark:bg-slate-800/50;
|
@apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 p-4 text-center transition-colors cursor-pointer sm:p-6 lg:p-8 dark:border-slate-600 dark:bg-slate-800/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-zone:hover,
|
.upload-zone:hover,
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
Hero Upload Zone — premium glassmorphism card for the homepage
|
Hero Upload Zone — premium glassmorphism card for the homepage
|
||||||
────────────────────────────────────────────────────────────────────────── */
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
.hero-upload-zone {
|
.hero-upload-zone {
|
||||||
@apply relative flex flex-col items-center justify-center rounded-3xl border border-slate-200/80 bg-white/80 backdrop-blur-sm p-10 text-center transition-all duration-300 ease-in-out cursor-pointer sm:p-14 shadow-sm dark:border-slate-700/60 dark:bg-slate-800/60 dark:backdrop-blur-sm;
|
@apply relative flex flex-col items-center justify-center rounded-3xl border border-slate-200/80 bg-white/80 backdrop-blur-sm p-6 text-center transition-all duration-300 ease-in-out cursor-pointer sm:p-10 lg:p-14 shadow-sm dark:border-slate-700/60 dark:bg-slate-800/60 dark:backdrop-blur-sm;
|
||||||
background-image: radial-gradient(ellipse at top, rgba(219, 234, 254, 0.3) 0%, transparent 70%);
|
background-image: radial-gradient(ellipse at top, rgba(219, 234, 254, 0.3) 0%, transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,3 +262,51 @@
|
|||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: 1px 2000px;
|
contain-intrinsic-size: 1px 2000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────────────
|
||||||
|
Mobile-first enhancements
|
||||||
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Respect user preference for reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guard hover-dependent styles for touch devices */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.tool-card:hover {
|
||||||
|
--tw-translate-y: -0.25rem;
|
||||||
|
}
|
||||||
|
.hero-upload-zone:hover {
|
||||||
|
--tw-translate-y: -0.25rem;
|
||||||
|
}
|
||||||
|
.marketing-card:hover {
|
||||||
|
--tw-translate-y: -0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove 300ms tap delay and prevent double-tap zoom on interactive elements */
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
[role="button"] {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe-area inset support for notched phones */
|
||||||
|
.sticky-header-safe {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-safe {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|||||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/client" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
function getAllowedHosts() {
|
function getAllowedHosts() {
|
||||||
@@ -20,7 +21,61 @@ function getAllowedHosts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'prompt',
|
||||||
|
includeAssets: ['favicon.svg', 'logo.svg', 'icons/*.svg'],
|
||||||
|
manifest: false, // use the static manifest.json in public/
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
skipWaiting: false,
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api\//],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'google-fonts-stylesheets',
|
||||||
|
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'google-fonts-webfonts',
|
||||||
|
expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/i,
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: {
|
||||||
|
cacheName: 'images',
|
||||||
|
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/.*\.(?:js|css)$/i,
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: {
|
||||||
|
cacheName: 'static-resources',
|
||||||
|
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 7 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|||||||
Reference in New Issue
Block a user