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 './globals.css'
|
||||||
import { Navigation } from '@/components/Navigation'
|
import { Navigation } from '@/components/Navigation'
|
||||||
import { AuthProvider } from '@/context/AuthContext'
|
import { AuthProvider } from '@/context/AuthContext'
|
||||||
|
import { PWAProvider } from '@/components/PWAProvider'
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin', 'cyrillic'],
|
subsets: ['latin', 'cyrillic'],
|
||||||
@@ -15,6 +16,16 @@ export const metadata: Metadata = {
|
|||||||
description: 'Децентралізована платформа для мікро-спільнот з AI-агентами',
|
description: 'Децентралізована платформа для мікро-спільнот з AI-агентами',
|
||||||
keywords: ['DAARION', 'DAO', 'AI', 'agents', 'community', 'decentralized'],
|
keywords: ['DAARION', 'DAO', 'AI', 'agents', 'community', 'decentralized'],
|
||||||
authors: [{ name: 'DAARION Team' }],
|
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 = {
|
export const viewport: Viewport = {
|
||||||
@@ -33,6 +44,7 @@ export default function RootLayout({
|
|||||||
<html lang="uk" className={inter.variable}>
|
<html lang="uk" className={inter.variable}>
|
||||||
<body className={`${inter.className} antialiased`}>
|
<body className={`${inter.className} antialiased`}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<PWAProvider>
|
||||||
{/* Ambient background effect */}
|
{/* Ambient background effect */}
|
||||||
<div className="ambient-bg" />
|
<div className="ambient-bg" />
|
||||||
|
|
||||||
@@ -43,6 +55,7 @@ export default function RootLayout({
|
|||||||
<main className="min-h-screen pt-16">
|
<main className="min-h-screen pt-16">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
</PWAProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
243
docs/web/PWA_MOBILE_SPEC.md
Normal file
243
docs/web/PWA_MOBILE_SPEC.md
Normal 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
|
||||||
|
|
||||||
Reference in New Issue
Block a user