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" />
<PWAProvider>
{/* Ambient background effect */}
<div className="ambient-bg" />
{/* Navigation */}
<Navigation />
{/* Navigation */}
<Navigation />
{/* Main content */}
<main className="min-h-screen pt-16">
{children}
</main>
{/* 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;
}

243
docs/web/PWA_MOBILE_SPEC.md Normal file
View File

@@ -0,0 +1,243 @@
# PWA / Mobile Specification — DAARION.city
Version: 1.0.0
## 0. PURPOSE
Зробити `app.daarion.space` Progressive Web App (PWA):
- **Installable** — можна додати на домашній екран як додаток
- **Offline shell** — базові сторінки працюють без мережі
- **Mobile-first** — оптимізовано для мобільних пристроїв
- **Foundation for push** — підготовка до push-нотифікацій
---
## 1. GOALS
### 1.1. Installable
- Користувач може "Add to Home Screen" на iOS/Android/Desktop
- Відкривається в standalone режимі (без browser UI)
- Власна іконка та splash screen
### 1.2. Offline Shell
- Головна сторінка `/` працює з кешу
- Список кімнат `/city` працює з кешу
- Сторінки кімнат `/city/[slug]` — кешований shell + повідомлення "ви офлайн"
- Matrix чат — показує offline-стан, не намагається емулювати
### 1.3. Performance
- Швидке завантаження через кешування статичних ресурсів
- App shell architecture
---
## 2. MANIFEST.JSON
Файл: `public/manifest.json`
```json
{
"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"
}
```
---
## 3. ICONS
Директорія: `public/icons/`
| File | Size | Purpose |
|------|------|---------|
| `icon-192x192.png` | 192×192 | Android, Chrome |
| `icon-512x512.png` | 512×512 | Splash screen |
| `apple-touch-icon.png` | 180×180 | iOS |
| `favicon.ico` | 32×32 | Browser tab |
Дизайн: DAARION логотип (sparkles/зірка) на темному фоні (#0f172a)
---
## 4. SERVICE WORKER
Файл: `public/sw.js`
### 4.1. Cache Strategy
```
┌─────────────────────────────────────────────────────────────┐
│ REQUEST FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ Static Assets (/_next/static/*, fonts, images) │
│ └── Cache First → Return cached → Update in background │
│ │
│ HTML Pages (/, /city, /city/*) │
│ └── Network First → Fallback to cache → Offline page │
│ │
│ API Requests (/api/*) │
│ └── Network Only → No caching │
│ │
│ Matrix/WebSocket │
│ └── Network Only → Show offline state in UI │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 4.2. Cache Names
```javascript
const CACHE_NAME = 'daarion-v1';
const STATIC_CACHE = 'daarion-static-v1';
const PAGES_CACHE = 'daarion-pages-v1';
```
### 4.3. Precache List
```javascript
const PRECACHE_URLS = [
'/',
'/city',
'/offline',
'/manifest.json',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png'
];
```
---
## 5. OFFLINE PAGE
Файл: `src/app/offline/page.tsx`
Простий екран:
- DAARION логотип
- "Ви офлайн"
- "Перевірте підключення до інтернету"
- Кнопка "Спробувати знову"
---
## 6. HEAD METADATA
В `layout.tsx`:
```tsx
<head>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#0c4a6e" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<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="DAARION" />
</head>
```
---
## 7. SW REGISTRATION
В `src/lib/pwa.ts`:
```typescript
export function registerServiceWorker() {
if (typeof window === 'undefined') return;
if (!('serviceWorker' in navigator)) return;
// Only in production
if (process.env.NODE_ENV !== 'production') {
console.log('SW: Skipping registration in development');
return;
}
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('SW: Registered', registration.scope);
} catch (error) {
console.error('SW: Registration failed', error);
}
});
}
```
---
## 8. TESTING CHECKLIST
### 8.1. DevTools → Application
- [ ] Manifest loads correctly
- [ ] Icons display properly
- [ ] Service Worker is active
- [ ] Cache Storage has expected caches
### 8.2. Install Prompt
- [ ] Chrome shows "Install app" option
- [ ] Safari iOS shows "Add to Home Screen"
- [ ] App opens in standalone mode
### 8.3. Offline Mode
- [ ] `/` loads from cache
- [ ] `/city` loads from cache
- [ ] `/city/general` shows chat offline state
- [ ] API calls show appropriate error
### 8.4. Lighthouse
- [ ] PWA score > 90
- [ ] Performance > 80
- [ ] Accessibility > 90
---
## 9. LIMITATIONS (MVP)
- ❌ No push notifications (future phase)
- ❌ No background sync
- ❌ No offline message queue
- ❌ No IndexedDB for offline data
---
## 10. FUTURE ENHANCEMENTS
1. **Push Notifications**
- Web Push API
- VAPID keys
- Notification preferences
2. **Background Sync**
- Queue messages when offline
- Sync when back online
3. **IndexedDB**
- Cache chat history
- Offline-first architecture
4. **App Badges**
- Unread message count on icon