feat: добавлена поддержка PWA (v3.8.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s

- Установлен vite-plugin-pwa для поддержки Progressive Web App
- Созданы иконки приложения для всех платформ (iOS, Android, Desktop)
- Настроен Service Worker с кэшированием статики и API данных
- Добавлен компонент PWAUpdatePrompt для уведомлений об обновлениях
- Обновлены конфигурации nginx для корректной работы Service Worker
- Добавлены PWA meta-теги в index.html
- Создан скрипт generate-icons.cjs для генерации иконок
This commit is contained in:
poignatov
2026-01-10 21:46:54 +03:00
parent dde8858d7d
commit 11e0d0074c
16 changed files with 5449 additions and 8 deletions

View File

@@ -0,0 +1,78 @@
// Скрипт для генерации базовых PWA иконок
// Требует: npm install sharp
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const publicDir = path.join(__dirname, 'public');
// Создаем SVG шаблон для иконки
const createIconSVG = (size) => `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<text x="50" y="70" font-family="Arial, sans-serif" font-size="60" font-weight="bold" fill="white" text-anchor="middle">P</text>
</svg>
`;
async function generateIcons() {
// Создаем базовый SVG
const baseSVG = createIconSVG(512);
const svgBuffer = Buffer.from(baseSVG);
// Генерируем иконки разных размеров
const sizes = [
{ name: 'favicon.ico', size: 32 },
{ name: 'apple-touch-icon.png', size: 180 },
{ name: 'pwa-192x192.png', size: 192 },
{ name: 'pwa-512x512.png', size: 512 },
{ name: 'pwa-maskable-512x512.png', size: 512, maskable: true }
];
for (const icon of sizes) {
let image = sharp(svgBuffer).resize(icon.size, icon.size);
if (icon.maskable) {
// Для maskable иконки добавляем padding (контент в центральных 80%)
const padding = icon.size * 0.1;
image = sharp({
create: {
width: icon.size,
height: icon.size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.composite([{
input: await sharp(svgBuffer).resize(Math.round(icon.size * 0.8), Math.round(icon.size * 0.8)).toBuffer(),
left: Math.round(padding),
top: Math.round(padding)
}]);
}
const outputPath = path.join(publicDir, icon.name);
await image.png().toFile(outputPath);
console.log(`✓ Создана иконка: ${icon.name} (${icon.size}x${icon.size})`);
}
console.log('\n✓ Все иконки успешно созданы!');
}
// Проверяем наличие sharp
try {
require('sharp');
generateIcons().catch(console.error);
} catch (e) {
console.log('Для генерации иконок необходимо установить sharp:');
console.log('npm install sharp --save-dev');
console.log('\nИли создайте иконки вручную используя онлайн генераторы:');
console.log('- https://realfavicongenerator.net/');
console.log('- https://www.pwabuilder.com/imageGenerator');
}

View File

@@ -2,8 +2,18 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#4f46e5" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PlayLife" />
<meta name="description" content="Трекер продуктивности и изучения слов" />
<title>PlayLife - Статистика</title>
</head>
<body>

View File

@@ -36,6 +36,18 @@ server {
proxy_cache_bypass $http_upgrade;
}
# Service Worker должен быть без кэширования
location /sw.js {
add_header Cache-Control "no-cache";
expires 0;
}
# Manifest тоже без долгого кэширования
location /manifest.webmanifest {
add_header Cache-Control "no-cache";
expires 0;
}
# Handle React Router (SPA)
location / {
try_files $uri $uri/ /index.html;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "3.7.0",
"version": "3.8.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -22,7 +22,9 @@
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"sharp": "^0.34.5",
"tailwindcss": "^3.3.6",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<text x="50" y="70" font-family="Arial, sans-serif" font-size="60" font-weight="bold" fill="white" text-anchor="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -14,6 +14,7 @@ import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration'
import { AuthProvider, useAuth } from './components/auth/AuthContext'
import AuthScreen from './components/auth/AuthScreen'
import PWAUpdatePrompt from './components/PWAUpdatePrompt'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const CURRENT_WEEK_API_URL = '/playlife-feed'
@@ -962,6 +963,7 @@ function App() {
return (
<AuthProvider>
<AppContent />
<PWAUpdatePrompt />
</AuthProvider>
)
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
export default function PWAUpdatePrompt() {
const [showPrompt, setShowPrompt] = useState(false)
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker
} = useRegisterSW({
onRegistered(r) {
console.log('SW зарегистрирован:', r)
},
onRegisterError(error) {
console.log('SW ошибка регистрации:', error)
}
})
useEffect(() => {
if (needRefresh) {
setShowPrompt(true)
}
}, [needRefresh])
const handleUpdate = () => {
updateServiceWorker(true)
setShowPrompt(false)
}
const handleDismiss = () => {
setNeedRefresh(false)
setShowPrompt(false)
}
if (!showPrompt) return null
return (
<div className="fixed bottom-24 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-white rounded-lg shadow-lg border border-gray-200 p-4 z-50">
<p className="text-sm text-gray-700 mb-3">
Доступна новая версия приложения
</p>
<div className="flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 px-3 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700"
>
Обновить
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200"
>
Позже
</button>
</div>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { resolve } from 'path'
export default defineConfig(({ mode }) => {
@@ -12,7 +13,94 @@ export default defineConfig(({ mode }) => {
const env = { ...rootEnv, ...localEnv, ...process.env }
return {
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'favicon.svg'],
manifest: {
name: 'PlayLife - Статистика и задачи',
short_name: 'PlayLife',
description: 'Трекер продуктивности и изучения слов',
theme_color: '#4f46e5',
background_color: '#f3f4f6',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
scope: '/',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-maskable-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
},
workbox: {
// Кэширование статики
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
// Стратегии для API
runtimeCaching: [
{
// Кэширование данных текущей недели
urlPattern: /\/playlife-feed$/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-current-week',
expiration: {
maxEntries: 1,
maxAgeSeconds: 60 * 60 // 1 час
},
networkTimeoutSeconds: 10
}
},
{
// Кэширование полной статистики
urlPattern: /\/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b$/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-full-statistics',
expiration: {
maxEntries: 1,
maxAgeSeconds: 60 * 60 // 1 час
},
networkTimeoutSeconds: 10
}
},
{
// Кэширование списка задач
urlPattern: /\/api\/tasks$/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-tasks',
expiration: {
maxEntries: 1,
maxAgeSeconds: 60 * 60 // 1 час
},
networkTimeoutSeconds: 10
}
},
{
// Остальные API запросы - только сеть (не кэшировать)
urlPattern: /\/api\/.*/,
handler: 'NetworkOnly'
}
]
}
})
],
server: {
host: '0.0.0.0',
port: parseInt(env.VITE_PORT || '3000', 10),