feat: add PWA support with service worker and update prompt
- Updated package.json to include vite-plugin-pwa and workbox-window. - Added icon SVGs for PWA: icon-512.svg and maskable-512.svg. - Created a manifest.json for PWA configuration. - Implemented PwaUpdatePrompt component to notify users of available updates. - Enhanced CookieConsent and SiteAssistant components for better layout and responsiveness. - Updated global CSS for safe-area insets and mobile-first enhancements. - Registered service worker in usePwaRegistration hook for managing updates. - Modified Vite configuration to integrate PWA features and caching strategies.
This commit is contained in:
@@ -4,11 +4,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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"
|
||||
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="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="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
|
||||
<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",
|
||||
"typescript": "^5.5.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 CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
|
||||
const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant'));
|
||||
const PwaUpdatePrompt = lazy(() => import('@/components/layout/PwaUpdatePrompt'));
|
||||
|
||||
// Tool components — derived from manifest using React.lazy
|
||||
const ToolComponents = Object.fromEntries(
|
||||
@@ -169,6 +170,7 @@ export default function App() {
|
||||
<SiteAssistant />
|
||||
</IdleLoad>
|
||||
<CookieConsent />
|
||||
<PwaUpdatePrompt />
|
||||
</Suspense>
|
||||
<Toaster
|
||||
position={isRTL ? 'top-left' : 'top-right'}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function CookieConsent() {
|
||||
<div
|
||||
role="dialog"
|
||||
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="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(' ');
|
||||
|
||||
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="flex items-center gap-8">
|
||||
<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 (
|
||||
<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">
|
||||
{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">
|
||||
@@ -225,7 +225,7 @@ export default function SiteAssistant() {
|
||||
</p>
|
||||
</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 && (
|
||||
<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">
|
||||
|
||||
@@ -95,8 +95,8 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
{t('common.download')} — {result.filename}
|
||||
<Download className="h-5 w-5 shrink-0" />
|
||||
<span className="truncate">{t('common.download')} — {result.filename}</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function ProgressBar({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 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">
|
||||
{isActive && (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" />
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function SharePanel({
|
||||
</button>
|
||||
|
||||
{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">
|
||||
<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')}
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function ToolSelectorModal({
|
||||
aria-modal="true"
|
||||
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 */}
|
||||
<div className="mb-5 flex items-start justify-between">
|
||||
<div>
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function ChatPdf() {
|
||||
<textarea
|
||||
value={question} onChange={(e) => setQuestion(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function ImageConverter() {
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
Convert to:
|
||||
</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) => (
|
||||
<button
|
||||
key={f.value}
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function ImageResize() {
|
||||
{t('tools.imageResize.lockAspect')}
|
||||
</label>
|
||||
</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>
|
||||
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
|
||||
{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">
|
||||
{t('tools.compressPdf.quality', { defaultValue: 'Compression Quality' })}
|
||||
</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) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
|
||||
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 {
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -41,15 +42,15 @@
|
||||
|
||||
@layer components {
|
||||
.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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@@ -71,7 +72,7 @@
|
||||
|
||||
/* Upload zone styles */
|
||||
.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,
|
||||
@@ -118,7 +119,7 @@
|
||||
Hero Upload Zone — premium glassmorphism card for the homepage
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.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%);
|
||||
}
|
||||
|
||||
@@ -261,3 +262,51 @@
|
||||
content-visibility: auto;
|
||||
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-plugin-pwa/client" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import path from 'path';
|
||||
|
||||
function getAllowedHosts() {
|
||||
@@ -20,7 +21,61 @@ function getAllowedHosts() {
|
||||
}
|
||||
|
||||
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: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
Reference in New Issue
Block a user