diff --git a/apps/web/public/icons/apple-touch-icon.png b/apps/web/public/icons/apple-touch-icon.png new file mode 100644 index 00000000..a6e3f749 Binary files /dev/null and b/apps/web/public/icons/apple-touch-icon.png differ diff --git a/apps/web/public/icons/generate-icons.js b/apps/web/public/icons/generate-icons.js new file mode 100644 index 00000000..66beb73b --- /dev/null +++ b/apps/web/public/icons/generate-icons.js @@ -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); +} diff --git a/apps/web/public/icons/icon-192x192.png b/apps/web/public/icons/icon-192x192.png new file mode 100644 index 00000000..a6e3f749 Binary files /dev/null and b/apps/web/public/icons/icon-192x192.png differ diff --git a/apps/web/public/icons/icon-512x512.png b/apps/web/public/icons/icon-512x512.png new file mode 100644 index 00000000..a6e3f749 Binary files /dev/null and b/apps/web/public/icons/icon-512x512.png differ diff --git a/apps/web/public/icons/icon.svg b/apps/web/public/icons/icon.svg new file mode 100644 index 00000000..be8ed133 --- /dev/null +++ b/apps/web/public/icons/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 00000000..516a27c2 --- /dev/null +++ b/apps/web/public/manifest.json @@ -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" +} + diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 00000000..b2fb4df6 --- /dev/null +++ b/apps/web/public/sw.js @@ -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( + '

Ви офлайн

Перевірте підключення до інтернету

', + { + status: 503, + headers: { 'Content-Type': 'text/html; charset=utf-8' } + } + ); + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 856f7a70..89da0920 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -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({ - {/* Ambient background effect */} -
- - {/* Navigation */} - - - {/* Main content */} -
- {children} -
+ + {/* Ambient background effect */} +
+ + {/* Navigation */} + + + {/* Main content */} +
+ {children} +
+ diff --git a/apps/web/src/app/offline/page.tsx b/apps/web/src/app/offline/page.tsx new file mode 100644 index 00000000..e2702519 --- /dev/null +++ b/apps/web/src/app/offline/page.tsx @@ -0,0 +1,53 @@ +'use client' + +import { WifiOff, RefreshCw, Sparkles } from 'lucide-react' + +export default function OfflinePage() { + const handleRetry = () => { + window.location.reload() + } + + return ( +
+
+ {/* Logo */} +
+
+ +
+
+ + {/* Offline Icon */} +
+
+ +
+
+ + {/* Message */} +

+ Ви офлайн +

+

+ Перевірте підключення до інтернету та спробуйте ще раз. + Деякі раніше відвідані сторінки можуть бути доступні з кешу. +

+ + {/* Retry Button */} + + + {/* Info */} +

+ DAARION.city працює краще з інтернет-з'єднанням +

+
+
+ ) +} + diff --git a/apps/web/src/components/PWAProvider.tsx b/apps/web/src/components/PWAProvider.tsx new file mode 100644 index 00000000..351d478e --- /dev/null +++ b/apps/web/src/components/PWAProvider.tsx @@ -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} +} + diff --git a/apps/web/src/lib/pwa.ts b/apps/web/src/lib/pwa.ts new file mode 100644 index 00000000..1fac03f6 --- /dev/null +++ b/apps/web/src/lib/pwa.ts @@ -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; +} + diff --git a/docs/web/PWA_MOBILE_SPEC.md b/docs/web/PWA_MOBILE_SPEC.md new file mode 100644 index 00000000..e076a05b --- /dev/null +++ b/docs/web/PWA_MOBILE_SPEC.md @@ -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 + + + + + + + + +``` + +--- + +## 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 +