feat: добавлена поддержка PWA (v3.8.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
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:
@@ -62,6 +62,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;
|
||||
|
||||
78
play-life-web/generate-icons.cjs
Normal file
78
play-life-web/generate-icons.cjs
Normal 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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
5173
play-life-web/package-lock.json
generated
5173
play-life-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
play-life-web/public/apple-touch-icon.png
Normal file
BIN
play-life-web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
play-life-web/public/favicon.ico
Normal file
BIN
play-life-web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 732 B |
11
play-life-web/public/favicon.svg
Normal file
11
play-life-web/public/favicon.svg
Normal 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 |
BIN
play-life-web/public/pwa-192x192.png
Normal file
BIN
play-life-web/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
play-life-web/public/pwa-512x512.png
Normal file
BIN
play-life-web/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
play-life-web/public/pwa-maskable-512x512.png
Normal file
BIN
play-life-web/public/pwa-maskable-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
59
play-life-web/src/components/PWAUpdatePrompt.jsx
Normal file
59
play-life-web/src/components/PWAUpdatePrompt.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user