Skip to content

Commit c02ee95

Browse files
committed
feat: pwa setup for mobile
1 parent 5178f31 commit c02ee95

File tree

9 files changed

+3561
-317
lines changed

9 files changed

+3561
-317
lines changed

src/client/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,8 @@ yarn-error.log*
3737
*.tsbuildinfo
3838
next-env.d.ts
3939

40+
# PWA files
41+
/public/sw.js
42+
/public/workbox-*.js
43+
4044
# End of https://www.toptal.com/developers/gitignore/api/nextjs

src/client/app/layout.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ export default function RootLayout({ children }) {
6262
sizes="16x16"
6363
href="/favicon-16x16.png"
6464
/>
65-
<link rel="manifest" href="/site.webmanifest" />
65+
<meta name="theme-color" content="#000000" />
66+
<link rel="manifest" href="/manifest.json" />
6667
</head>
6768
<body className="font-sans">
6869
<Auth0Provider>

src/client/components/LayoutWrapper.js

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client"
22
import React, { useState, useEffect, useCallback, useRef } from "react"
33
import { usePathname, useRouter } from "next/navigation"
4-
import { AnimatePresence, motion } from "framer-motion"
4+
import { AnimatePresence } from "framer-motion"
55
import NotificationsOverlay from "@components/NotificationsOverlay"
66
import { IconBell, IconMenu2, IconLoader } from "@tabler/icons-react"
77
import Sidebar from "@components/Sidebar"
@@ -144,6 +144,58 @@ export default function LayoutWrapper({ children }) {
144144
setCommandPaletteOpen((prev) => !prev)
145145
)
146146

147+
// PWA Update Handler
148+
useEffect(() => {
149+
if (
150+
typeof window !== "undefined" &&
151+
"serviceWorker" in navigator &&
152+
window.workbox !== undefined
153+
) {
154+
const wb = window.workbox
155+
156+
const promptNewVersionAvailable = (event) => {
157+
if (!event.wasWaitingBeforeRegister) {
158+
toast(
159+
(t) => (
160+
<div className="flex flex-col items-center gap-2 text-white">
161+
<span>A new version is available!</span>
162+
<div className="flex gap-2">
163+
<button
164+
className="py-1 px-3 rounded-md bg-green-600 hover:bg-green-500 text-white text-sm font-medium"
165+
onClick={() => {
166+
wb.addEventListener(
167+
"controlling",
168+
() => {
169+
window.location.reload()
170+
}
171+
)
172+
wb.messageSkipWaiting()
173+
toast.dismiss(t.id)
174+
}}
175+
>
176+
Refresh
177+
</button>
178+
<button
179+
className="py-1 px-3 rounded-md bg-neutral-600 hover:bg-neutral-500 text-white text-sm font-medium"
180+
onClick={() => toast.dismiss(t.id)}
181+
>
182+
Dismiss
183+
</button>
184+
</div>
185+
</div>
186+
),
187+
{ duration: Infinity }
188+
)
189+
}
190+
}
191+
192+
wb.addEventListener("waiting", promptNewVersionAvailable)
193+
return () => {
194+
wb.removeEventListener("waiting", promptNewVersionAvailable)
195+
}
196+
}
197+
}, [])
198+
147199
useEffect(() => {
148200
const handleEscape = (e) => {
149201
if (e.key === "Escape") {

src/client/components/Sidebar.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
IconHeadphones,
1919
IconDots,
2020
IconLogout,
21-
IconX
21+
IconX,
22+
IconDownload
2223
} from "@tabler/icons-react"
2324
import { cn } from "@utils/cn"
2425
import { motion, AnimatePresence } from "framer-motion"
@@ -69,11 +70,40 @@ const SidebarContent = ({
6970
const pathname = usePathname()
7071
const [userDetails, setUserDetails] = useState(null)
7172
const [isHelpModalOpen, setHelpModalOpen] = useState(false)
73+
const [installPrompt, setInstallPrompt] = useState(null)
7274
const [isUserMenuOpen, setUserMenuOpen] = useState(false)
7375
const userMenuRef = useRef(null)
7476

7577
useClickOutside(userMenuRef, () => setUserMenuOpen(false))
7678

79+
useEffect(() => {
80+
const handleBeforeInstallPrompt = (e) => {
81+
// Prevent the mini-infobar from appearing on mobile
82+
e.preventDefault()
83+
// Stash the event so it can be triggered later.
84+
setInstallPrompt(e)
85+
}
86+
87+
window.addEventListener(
88+
"beforeinstallprompt",
89+
handleBeforeInstallPrompt
90+
)
91+
92+
return () => {
93+
window.removeEventListener(
94+
"beforeinstallprompt",
95+
handleBeforeInstallPrompt
96+
)
97+
}
98+
}, [])
99+
100+
const handleInstallClick = async () => {
101+
if (!installPrompt) return
102+
const result = await installPrompt.prompt()
103+
console.log(`Install prompt was: ${result.outcome}`)
104+
setInstallPrompt(null)
105+
}
106+
77107
useEffect(() => {
78108
fetch("/api/user/profile")
79109
.then((res) => (res.ok ? res.json() : null))
@@ -225,6 +255,22 @@ const SidebarContent = ({
225255

226256
{/* Footer */}
227257
<div className="flex flex-col gap-2">
258+
{installPrompt && (
259+
<button
260+
onClick={handleInstallClick}
261+
className={cn(
262+
"w-full flex items-center gap-3 bg-green-600/20 border border-green-500/50 text-green-300 rounded-lg p-2 text-left text-sm hover:bg-green-600/40 transition-colors",
263+
isCollapsed && "justify-center"
264+
)}
265+
>
266+
<IconDownload size={20} className="flex-shrink-0" />
267+
{!isCollapsed && (
268+
<span className="font-medium whitespace-nowrap">
269+
Install App
270+
</span>
271+
)}
272+
</button>
273+
)}
228274
{isMobile ? (
229275
<button
230276
onClick={onMobileClose}

src/client/next.config.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,47 @@
11
// src/client/next.config.js
22

33
/** @type {import('next').NextConfig} */
4+
import nextPWA from "next-pwa"
5+
6+
const withPWA = nextPWA({
7+
dest: "public",
8+
disable: process.env.NODE_ENV === "development",
9+
register: true,
10+
skipWaiting: false,
11+
runtimeCaching: [
12+
// Cache Google Fonts
13+
{
14+
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
15+
handler: "CacheFirst",
16+
options: {
17+
cacheName: "google-fonts-cache",
18+
expiration: {
19+
maxEntries: 10,
20+
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
21+
},
22+
cacheableResponse: {
23+
statuses: [0, 200]
24+
}
25+
}
26+
},
27+
// Cache API data with a Stale-While-Revalidate strategy
28+
{
29+
urlPattern: /\/api\//, // Match any API route
30+
handler: "StaleWhileRevalidate",
31+
options: {
32+
cacheName: "api-data-cache",
33+
expiration: {
34+
maxEntries: 50,
35+
maxAgeSeconds: 60 * 60 // 1 hour
36+
},
37+
cacheableResponse: {
38+
statuses: [200]
39+
}
40+
}
41+
}
42+
]
43+
})
44+
445
const nextConfig = {
546
images: {
647
unoptimized: true
@@ -44,4 +85,4 @@ if (process.env.NODE_ENV === "production") {
4485
nextConfig.output = "standalone"
4586
}
4687

47-
export default nextConfig
88+
export default withPWA(nextConfig)

0 commit comments

Comments
 (0)