diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 00000000..1c3c3402 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 00000000..1ba41e3f Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512-maskable.png b/frontend/public/icon-512-maskable.png new file mode 100644 index 00000000..e5e7cb63 Binary files /dev/null and b/frontend/public/icon-512-maskable.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 00000000..97c95700 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/sw.js b/frontend/public/sw.js index ec07d267..fa7cba04 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -7,6 +7,16 @@ */ const SW_VERSION = '2.0.0'; +const STATIC_CACHE = `eusrr-static-${SW_VERSION}`; +const PRECACHE_URLS = [ + '/manifest.webmanifest', + '/logo.png', + '/logo.webp', + '/icon-192.png', + '/icon-512.png', + '/icon-512-maskable.png', + '/apple-touch-icon.png', +]; // Настройки по умолчанию для уведомлений const DEFAULT_NOTIFICATION_OPTIONS = { @@ -22,8 +32,15 @@ const DEFAULT_NOTIFICATION_OPTIONS = { */ self.addEventListener('install', (event) => { console.log('[SW] Service Worker installing, version:', SW_VERSION); - // Немедленно активировать новую версию - self.skipWaiting(); + event.waitUntil( + caches + .open(STATIC_CACHE) + .then((cache) => cache.addAll(PRECACHE_URLS)) + .catch((error) => { + console.error('[SW] Failed to precache static assets:', error); + }) + .then(() => self.skipWaiting()) + ); }); /** @@ -31,8 +48,56 @@ self.addEventListener('install', (event) => { */ self.addEventListener('activate', (event) => { console.log('[SW] Service Worker activated, version:', SW_VERSION); - // Захватить все клиенты без перезагрузки - event.waitUntil(self.clients.claim()); + event.waitUntil( + caches.keys().then((cacheKeys) => + Promise.all( + cacheKeys.map((cacheKey) => { + if (cacheKey !== STATIC_CACHE && cacheKey.startsWith('eusrr-static-')) { + return caches.delete(cacheKey); + } + return Promise.resolve(false); + }) + ) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + + if (request.method !== 'GET') { + return; + } + + const url = new URL(request.url); + if (url.origin !== self.location.origin) { + return; + } + + const isApiRequest = url.pathname.startsWith('/api/'); + const isStaticAsset = + url.pathname.startsWith('/_next/static/') || + PRECACHE_URLS.includes(url.pathname) || + ['image', 'style', 'script', 'font', 'worker'].includes(request.destination); + + if (isApiRequest || !isStaticAsset) { + return; + } + + event.respondWith( + caches.open(STATIC_CACHE).then(async (cache) => { + const cachedResponse = await cache.match(request); + if (cachedResponse) { + return cachedResponse; + } + + const networkResponse = await fetch(request); + if (networkResponse.ok) { + cache.put(request, networkResponse.clone()).catch(() => undefined); + } + return networkResponse; + }) + ); }); /** diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 9c831f2a..cd2f63c7 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -18,13 +18,31 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "EUSRR - Корпоративная система", description: "Корпоративная система управления EUSRR", + applicationName: "EUSRR", + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "EUSRR", + }, + icons: { + icon: [ + { url: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }], + }, }; export const viewport: Viewport = { - width: 'device-width', + width: "device-width", initialScale: 1, maximumScale: 1, - interactiveWidget: 'resizes-content', + interactiveWidget: "resizes-content", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f4f8fc" }, + { media: "(prefers-color-scheme: dark)", color: "#111923" }, + ], }; export default function RootLayout({ diff --git a/frontend/src/app/manifest.ts b/frontend/src/app/manifest.ts new file mode 100644 index 00000000..656b1569 --- /dev/null +++ b/frontend/src/app/manifest.ts @@ -0,0 +1,32 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "EUSRR", + short_name: "EUSRR", + description: "Корпоративная система управления EUSRR", + start_url: "/", + scope: "/", + display: "standalone", + background_color: "#111923", + theme_color: "#111923", + icons: [ + { + src: "/icon-192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icon-512.png", + sizes: "512x512", + type: "image/png", + }, + { + src: "/icon-512-maskable.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 42b16670..e56b02e7 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -7,6 +7,7 @@ import { Bell, Check, Clock3, + Download, Eye, EyeOff, KeyRound, @@ -21,6 +22,7 @@ import { } from "lucide-react"; import { AppShell } from "@/components/AppShell"; +import { usePwa } from "@/contexts/PwaContext"; import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext"; import { formatSessionDateTime, @@ -126,6 +128,7 @@ function SectionCard({ export default function SettingsPage() { const { mobileNavPlacement, setMobileNavPlacement } = useMobileNavPlacement(); + const { canInstall, install, isInstalled, isRegistrationReady } = usePwa(); const { activeVerbCount, avatarInputRef, @@ -400,6 +403,61 @@ export default function SettingsPage() { + +
+
+
+ + + +
+

Статус установки

+

+ {isInstalled + ? "Приложение уже установлено на это устройство." + : canInstall + ? "Приложение можно установить через системный prompt браузера." + : "Установка через браузер сейчас недоступна."} +

+
+
+
+ +
+
+ + + +
+

PWA-компоненты

+

+ Service Worker: {isRegistrationReady ? "зарегистрирован" : "не зарегистрирован"}. +

+
+ +
+
+
+
+
+
+ + {/* eslint-disable-next-line @next/next/no-img-element */} Логотип setUserMenuOpen((v) => !v)} > {user?.avatar ? ( + /* eslint-disable-next-line @next/next/no-img-element */ {userName} ) : ( userInitials @@ -216,6 +220,18 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa className="app-action-ghost w-full px-4 py-2 text-left text-sm transition" onClick={() => { setUserMenuOpen(false); router.push('/settings'); }} >Настройки + {canInstall ? ( + + ) : null}