Skip to content

Commit 13f14ad

Browse files
authored
Merge pull request #215 from Chigybillionz/offline-support
Offline support
2 parents c9a7629 + afb50c5 commit 13f14ad

4 files changed

Lines changed: 308 additions & 26 deletions

File tree

frontend/next.config.js

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,195 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
44
enabled: process.env.ANALYZE === 'true',
55
});
66

7+
const withPWA = require('@ducanh2912/next-pwa').default({
8+
dest: 'public',
9+
cacheOnFrontEndNav: true,
10+
aggressiveFrontEndNavCaching: true,
11+
reloadOnOnline: true,
12+
swcMinify: true,
13+
disable: process.env.NODE_ENV === 'development',
14+
workboxOptions: {
15+
disableDevLogs: true,
16+
runtimeCaching: [
17+
{
18+
urlPattern: /^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,
19+
handler: 'CacheFirst',
20+
options: {
21+
cacheName: 'google-fonts-webfonts',
22+
expiration: {
23+
maxEntries: 4,
24+
maxAgeSeconds: 365 * 24 * 60 * 60, // 365 days
25+
},
26+
},
27+
},
28+
{
29+
urlPattern: /^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,
30+
handler: 'StaleWhileRevalidate',
31+
options: {
32+
cacheName: 'google-fonts-stylesheets',
33+
expiration: {
34+
maxEntries: 4,
35+
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
36+
},
37+
},
38+
},
39+
{
40+
urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,
41+
handler: 'StaleWhileRevalidate',
42+
options: {
43+
cacheName: 'static-font-assets',
44+
expiration: {
45+
maxEntries: 4,
46+
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
47+
},
48+
},
49+
},
50+
{
51+
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
52+
handler: 'StaleWhileRevalidate',
53+
options: {
54+
cacheName: 'static-image-assets',
55+
expiration: {
56+
maxEntries: 64,
57+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
58+
},
59+
},
60+
},
61+
{
62+
urlPattern: /\/_next\/image\?url=.+$/i,
63+
handler: 'StaleWhileRevalidate',
64+
options: {
65+
cacheName: 'next-image',
66+
expiration: {
67+
maxEntries: 64,
68+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
69+
},
70+
},
71+
},
72+
{
73+
urlPattern: /\.(?:mp3|wav|ogg)$/i,
74+
handler: 'CacheFirst',
75+
options: {
76+
rangeRequests: true,
77+
cacheName: 'static-audio-assets',
78+
expiration: {
79+
maxEntries: 32,
80+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
81+
},
82+
},
83+
},
84+
{
85+
urlPattern: /\.(?:mp4)$/i,
86+
handler: 'CacheFirst',
87+
options: {
88+
rangeRequests: true,
89+
cacheName: 'static-video-assets',
90+
expiration: {
91+
maxEntries: 32,
92+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
93+
},
94+
},
95+
},
96+
{
97+
urlPattern: /\.(?:js)$/i,
98+
handler: 'StaleWhileRevalidate',
99+
options: {
100+
cacheName: 'static-js-assets',
101+
expiration: {
102+
maxEntries: 32,
103+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
104+
},
105+
},
106+
},
107+
{
108+
urlPattern: /\.(?:css|less)$/i,
109+
handler: 'StaleWhileRevalidate',
110+
options: {
111+
cacheName: 'static-style-assets',
112+
expiration: {
113+
maxEntries: 32,
114+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
115+
},
116+
},
117+
},
118+
{
119+
urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
120+
handler: 'StaleWhileRevalidate',
121+
options: {
122+
cacheName: 'next-data',
123+
expiration: {
124+
maxEntries: 32,
125+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
126+
},
127+
},
128+
},
129+
{
130+
urlPattern: /\.(?:json|xml|csv)$/i,
131+
handler: 'NetworkFirst',
132+
options: {
133+
cacheName: 'static-data-assets',
134+
expiration: {
135+
maxEntries: 32,
136+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
137+
},
138+
},
139+
},
140+
{
141+
urlPattern: ({ url }) => {
142+
const isSameOrigin = self.origin === url.origin;
143+
if (!isSameOrigin) return false;
144+
const pathname = url.pathname;
145+
// Exclude /api/auth/callback/* to fix OAuth login issue
146+
if (pathname.startsWith('/api/auth/')) return false;
147+
if (pathname.startsWith('/api/')) return true;
148+
return false;
149+
},
150+
handler: 'NetworkFirst',
151+
method: 'GET',
152+
options: {
153+
cacheName: 'apis',
154+
expiration: {
155+
maxEntries: 16,
156+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
157+
},
158+
networkTimeoutSeconds: 10, // fall back to cache if api does not response within 10 seconds
159+
},
160+
},
161+
{
162+
urlPattern: ({ url }) => {
163+
const isSameOrigin = self.origin === url.origin;
164+
if (!isSameOrigin) return false;
165+
const pathname = url.pathname;
166+
if (pathname.startsWith('/api/')) return false;
167+
return true;
168+
},
169+
handler: 'NetworkFirst',
170+
options: {
171+
cacheName: 'others',
172+
expiration: {
173+
maxEntries: 32,
174+
maxAgeSeconds: 24 * 60 * 60, // 24 hours
175+
},
176+
networkTimeoutSeconds: 10,
177+
},
178+
},
179+
{
180+
urlPattern: ({ url }) => url.origin === self.origin && url.pathname.startsWith('/api/'),
181+
handler: 'NetworkOnly',
182+
method: 'POST',
183+
options: {
184+
backgroundSync: {
185+
name: 'api-sync-queue',
186+
options: {
187+
maxRetentionTime: 24 * 60, // Retry for max of 24 Hours
188+
},
189+
},
190+
},
191+
},
192+
],
193+
},
194+
});
195+
7196
const nextConfig = {
8197
typescript: {
9198
ignoreBuildErrors: true,
@@ -70,4 +259,4 @@ const nextConfig = {
70259
},
71260
};
72261

73-
module.exports = withBundleAnalyzer(nextConfig);
262+
module.exports = withBundleAnalyzer(withPWA(nextConfig));
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React, { useEffect, useState } from 'react';
2+
3+
export default function PWAInstallPrompt() {
4+
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
5+
const [showPrompt, setShowPrompt] = useState(false);
6+
7+
useEffect(() => {
8+
const handleBeforeInstallPrompt = (e: Event) => {
9+
e.preventDefault();
10+
setDeferredPrompt(e);
11+
setShowPrompt(true);
12+
};
13+
14+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
15+
16+
return () => {
17+
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
18+
};
19+
}, []);
20+
21+
const handleInstallClick = async () => {
22+
if (!deferredPrompt) return;
23+
deferredPrompt.prompt();
24+
const { outcome } = await deferredPrompt.userChoice;
25+
if (outcome === 'accepted') {
26+
setShowPrompt(false);
27+
}
28+
setDeferredPrompt(null);
29+
};
30+
31+
if (!showPrompt) return null;
32+
33+
return (
34+
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-white dark:bg-gray-800 p-4 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-50 flex flex-col gap-3">
35+
<div className="flex justify-between items-start">
36+
<div>
37+
<h3 className="font-semibold text-gray-900 dark:text-white">Install StarkEd</h3>
38+
<p className="text-sm text-gray-500 dark:text-gray-400">Install our app for a better, faster, offline-capable experience.</p>
39+
</div>
40+
<button
41+
onClick={() => setShowPrompt(false)}
42+
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
43+
aria-label="Close prompt"
44+
>
45+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
46+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
47+
</svg>
48+
</button>
49+
</div>
50+
<button
51+
onClick={handleInstallClick}
52+
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors"
53+
>
54+
Install App
55+
</button>
56+
</div>
57+
);
58+
}

frontend/src/pages/_app.tsx

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { AppProps } from 'next/app';
22
import { useEffect, useRef } from 'react';
33
import { useRouter } from 'next/router';
4+
import Head from 'next/head';
45
import { appWithTranslation } from 'next-i18next';
56
import { ThemeProvider } from 'next-themes';
67
import nextI18NextConfig from '../../next-i18next.config';
78
import { WalletProvider } from '../context/WalletContext';
89
import { ErrorBoundary } from '../components/ErrorBoundary';
910
import { GlobalShell } from '../components/PWA/GlobalShell';
11+
import PWAInstallPrompt from '../components/PWAInstallPrompt';
1012
import { Toaster } from 'react-hot-toast';
1113
import '../styles/globals.css';
1214

@@ -35,31 +37,40 @@ function MyApp({ Component, pageProps }: AppProps) {
3537
}, [router.asPath]);
3638

3739
return (
38-
<ThemeProvider attribute="class" defaultTheme="system" enableSystem storageKey="starked-theme">
39-
<ErrorBoundary key={router.asPath}>
40-
<WalletProvider>
41-
<a className="skip-link" href="#main-content">
42-
Skip to main content
43-
</a>
44-
<div
45-
className="sr-only"
46-
role="status"
47-
aria-live="polite"
48-
aria-atomic="true"
49-
>
50-
{`Navigated to ${router.asPath}`}
51-
</div>
52-
<GlobalShell />
53-
<Component {...pageProps} />
54-
<Toaster
55-
position="bottom-right"
56-
toastOptions={{
57-
ariaProps: { role: 'status', 'aria-live': 'polite' },
58-
}}
59-
/>
60-
</WalletProvider>
61-
</ErrorBoundary>
62-
</ThemeProvider>
40+
<>
41+
<Head>
42+
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
43+
<link rel="manifest" href="/manifest.json" />
44+
<meta name="theme-color" content="#3b82f6" />
45+
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
46+
</Head>
47+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem storageKey="starked-theme">
48+
<ErrorBoundary key={router.asPath}>
49+
<WalletProvider>
50+
<a className="skip-link" href="#main-content">
51+
Skip to main content
52+
</a>
53+
<div
54+
className="sr-only"
55+
role="status"
56+
aria-live="polite"
57+
aria-atomic="true"
58+
>
59+
{`Navigated to ${router.asPath}`}
60+
</div>
61+
<GlobalShell />
62+
<Component {...pageProps} />
63+
<PWAInstallPrompt />
64+
<Toaster
65+
position="bottom-right"
66+
toastOptions={{
67+
ariaProps: { role: 'status', 'aria-live': 'polite' },
68+
}}
69+
/>
70+
</WalletProvider>
71+
</ErrorBoundary>
72+
</ThemeProvider>
73+
</>
6374
);
6475
}
6576

frontend/src/pages/_offline.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
import Head from 'next/head';
3+
4+
export default function Offline() {
5+
return (
6+
<>
7+
<Head>
8+
<title>Offline | StarkEd</title>
9+
</Head>
10+
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 px-4 text-center">
11+
<h1 className="text-4xl font-bold mb-4">You are offline</h1>
12+
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8 max-w-md">
13+
It looks like you've lost your internet connection. Some features of StarkEd may be unavailable until you reconnect.
14+
</p>
15+
<button
16+
onClick={() => window.location.reload()}
17+
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
18+
>
19+
Try Again
20+
</button>
21+
</div>
22+
</>
23+
);
24+
}

0 commit comments

Comments
 (0)