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:
Apple
2025-11-26 13:56:22 -08:00
parent e0d534ea87
commit a3e632b9e7
12 changed files with 655 additions and 10 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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

View 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
View 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' }
}
);
}
}

View File

@@ -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>

View 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 працює краще з інтернет-з&apos;єднанням
</p>
</div>
</div>
)
}

View 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
View 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;
}