feat: Add PWA support
- PWA_MOBILE_SPEC.md documentation - manifest.json with app metadata - Service Worker with caching strategies - Offline page - PWA registration in layout - App icons (placeholder)
This commit is contained in:
BIN
apps/web/public/icons/apple-touch-icon.png
Normal file
BIN
apps/web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
61
apps/web/public/icons/generate-icons.js
Normal file
61
apps/web/public/icons/generate-icons.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const fs = require('fs');
|
||||
const { createCanvas } = require('canvas');
|
||||
|
||||
function generateIcon(size, filename) {
|
||||
const canvas = createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, size, size, size * 0.18);
|
||||
ctx.fill();
|
||||
|
||||
// Gradient for sparkle
|
||||
const gradient = ctx.createLinearGradient(0, 0, size, size);
|
||||
gradient.addColorStop(0, '#22d3ee');
|
||||
gradient.addColorStop(1, '#3b82f6');
|
||||
|
||||
// Draw sparkle
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const s = size * 0.23; // sparkle size
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - s);
|
||||
ctx.lineTo(cx + s * 0.12, cy - s * 0.12);
|
||||
ctx.lineTo(cx + s, cy);
|
||||
ctx.lineTo(cx + s * 0.12, cy + s * 0.12);
|
||||
ctx.lineTo(cx, cy + s);
|
||||
ctx.lineTo(cx - s * 0.12, cy + s * 0.12);
|
||||
ctx.lineTo(cx - s, cy);
|
||||
ctx.lineTo(cx - s * 0.12, cy - s * 0.12);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Small sparkles
|
||||
ctx.fillStyle = '#22d3ee';
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + size * 0.15, cy - size * 0.15, size * 0.023, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx - size * 0.14, cy + size * 0.14, size * 0.015, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
fs.writeFileSync(filename, buffer);
|
||||
console.log(`Generated ${filename}`);
|
||||
}
|
||||
|
||||
try {
|
||||
generateIcon(192, 'icon-192x192.png');
|
||||
generateIcon(512, 'icon-512x512.png');
|
||||
generateIcon(180, 'apple-touch-icon.png');
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
}
|
||||
BIN
apps/web/public/icons/icon-192x192.png
Normal file
BIN
apps/web/public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/web/public/icons/icon-512x512.png
Normal file
BIN
apps/web/public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
16
apps/web/public/icons/icon.svg
Normal file
16
apps/web/public/icons/icon.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<rect width="512" height="512" rx="96" fill="#0f172a"/>
|
||||
<defs>
|
||||
<linearGradient id="sparkle" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#22d3ee"/>
|
||||
<stop offset="100%" style="stop-color:#3b82f6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(256, 256)">
|
||||
<path d="M0 -120 L15 -15 L120 0 L15 15 L0 120 L-15 15 L-120 0 L-15 -15 Z" fill="url(#sparkle)" opacity="0.9"/>
|
||||
<circle cx="80" cy="-80" r="12" fill="#22d3ee" opacity="0.7"/>
|
||||
<circle cx="-70" cy="70" r="8" fill="#3b82f6" opacity="0.6"/>
|
||||
<circle cx="60" cy="90" r="6" fill="#22d3ee" opacity="0.5"/>
|
||||
<circle cx="-90" cy="-50" r="10" fill="#3b82f6" opacity="0.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
28
apps/web/public/manifest.json
Normal file
28
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "DAARION.city",
|
||||
"short_name": "DAARION",
|
||||
"description": "Децентралізована платформа для мікро-спільнот з AI-агентами",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"theme_color": "#0c4a6e",
|
||||
"background_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["social", "productivity"],
|
||||
"lang": "uk"
|
||||
}
|
||||
|
||||
135
apps/web/public/sw.js
Normal file
135
apps/web/public/sw.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// DAARION Service Worker v1.0
|
||||
const CACHE_VERSION = 'v1';
|
||||
const STATIC_CACHE = `daarion-static-${CACHE_VERSION}`;
|
||||
const PAGES_CACHE = `daarion-pages-${CACHE_VERSION}`;
|
||||
|
||||
// Files to precache
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/city',
|
||||
'/offline',
|
||||
'/manifest.json',
|
||||
'/icons/icon-192x192.png',
|
||||
'/icons/icon-512x512.png'
|
||||
];
|
||||
|
||||
// Install event - precache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing...');
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE)
|
||||
.then((cache) => {
|
||||
console.log('[SW] Precaching app shell');
|
||||
return cache.addAll(PRECACHE_URLS);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating...');
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name.startsWith('daarion-') && name !== STATIC_CACHE && name !== PAGES_CACHE)
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - handle requests
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
// Skip API and WebSocket requests
|
||||
if (url.pathname.startsWith('/api/') ||
|
||||
url.pathname.startsWith('/ws/') ||
|
||||
url.pathname.startsWith('/_matrix/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets - Cache First
|
||||
if (url.pathname.startsWith('/_next/static/') ||
|
||||
url.pathname.match(/\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$/)) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages - Network First with fallback
|
||||
if (request.headers.get('accept')?.includes('text/html')) {
|
||||
event.respondWith(networkFirstWithFallback(request, PAGES_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default - try network
|
||||
event.respondWith(fetch(request));
|
||||
});
|
||||
|
||||
// Cache First strategy
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
// Update cache in background
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
caches.open(cacheName).then((cache) => cache.put(request, response));
|
||||
}
|
||||
}).catch(() => {});
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Cache first failed:', request.url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Network First with fallback strategy
|
||||
async function networkFirstWithFallback(request, cacheName) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Network failed, trying cache:', request.url);
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Return offline page for navigation requests
|
||||
const offlineResponse = await caches.match('/offline');
|
||||
if (offlineResponse) {
|
||||
return offlineResponse;
|
||||
}
|
||||
|
||||
// Return a basic offline response
|
||||
return new Response(
|
||||
'<html><body><h1>Ви офлайн</h1><p>Перевірте підключення до інтернету</p></body></html>',
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
import { AuthProvider } from '@/context/AuthContext'
|
||||
import { PWAProvider } from '@/components/PWAProvider'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
@@ -15,6 +16,16 @@ export const metadata: Metadata = {
|
||||
description: 'Децентралізована платформа для мікро-спільнот з AI-агентами',
|
||||
keywords: ['DAARION', 'DAO', 'AI', 'agents', 'community', 'decentralized'],
|
||||
authors: [{ name: 'DAARION Team' }],
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'DAARION',
|
||||
},
|
||||
icons: {
|
||||
icon: '/icons/icon-192x192.png',
|
||||
apple: '/icons/apple-touch-icon.png',
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -33,16 +44,18 @@ export default function RootLayout({
|
||||
<html lang="uk" className={inter.variable}>
|
||||
<body className={`${inter.className} antialiased`}>
|
||||
<AuthProvider>
|
||||
{/* Ambient background effect */}
|
||||
<div className="ambient-bg" />
|
||||
|
||||
{/* Navigation */}
|
||||
<Navigation />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="min-h-screen pt-16">
|
||||
{children}
|
||||
</main>
|
||||
<PWAProvider>
|
||||
{/* Ambient background effect */}
|
||||
<div className="ambient-bg" />
|
||||
|
||||
{/* Navigation */}
|
||||
<Navigation />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="min-h-screen pt-16">
|
||||
{children}
|
||||
</main>
|
||||
</PWAProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
53
apps/web/src/app/offline/page.tsx
Normal file
53
apps/web/src/app/offline/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { WifiOff, RefreshCw, Sparkles } from 'lucide-react'
|
||||
|
||||
export default function OfflinePage() {
|
||||
const handleRetry = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="text-center max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-20 h-20 rounded-2xl bg-slate-800/50 flex items-center justify-center">
|
||||
<Sparkles className="w-10 h-10 text-cyan-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline Icon */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-500/20 flex items-center justify-center">
|
||||
<WifiOff className="w-8 h-8 text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<h1 className="text-2xl font-bold text-white mb-3">
|
||||
Ви офлайн
|
||||
</h1>
|
||||
<p className="text-slate-400 mb-8">
|
||||
Перевірте підключення до інтернету та спробуйте ще раз.
|
||||
Деякі раніше відвідані сторінки можуть бути доступні з кешу.
|
||||
</p>
|
||||
|
||||
{/* Retry Button */}
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-cyan-500 to-blue-600 text-white font-medium rounded-xl hover:from-cyan-400 hover:to-blue-500 transition-all shadow-lg shadow-cyan-500/20"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Спробувати знову
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<p className="mt-8 text-sm text-slate-500">
|
||||
DAARION.city працює краще з інтернет-з'єднанням
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
13
apps/web/src/components/PWAProvider.tsx
Normal file
13
apps/web/src/components/PWAProvider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { registerServiceWorker } from '@/lib/pwa'
|
||||
|
||||
export function PWAProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
registerServiceWorker()
|
||||
}, [])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
83
apps/web/src/lib/pwa.ts
Normal file
83
apps/web/src/lib/pwa.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* PWA utilities for DAARION
|
||||
*/
|
||||
|
||||
export function registerServiceWorker(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
console.log('[PWA] Service Worker not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only register in production
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('[PWA] Skipping SW registration in development');
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/'
|
||||
});
|
||||
|
||||
console.log('[PWA] Service Worker registered:', registration.scope);
|
||||
|
||||
// Check for updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New content available
|
||||
console.log('[PWA] New content available, refresh to update');
|
||||
// Could show a toast notification here
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PWA] Service Worker registration failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterServiceWorker(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.unregister().then((success) => {
|
||||
if (success) {
|
||||
console.log('[PWA] Service Worker unregistered');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app is running in standalone mode (installed PWA)
|
||||
*/
|
||||
export function isStandalone(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app can be installed
|
||||
*/
|
||||
export function canInstall(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// Check if already installed
|
||||
if (isStandalone()) return false;
|
||||
|
||||
// Check for beforeinstallprompt support
|
||||
return 'BeforeInstallPromptEvent' in window ||
|
||||
'onbeforeinstallprompt' in window;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user