فهرست منبع

feat(pwa): implement Progressive Web App (PWA) (#42)

Mat B. 1 ماه پیش
والد
کامیت
5480276fe1
6فایلهای تغییر یافته به همراه125 افزوده شده و 4 حذف شده
  1. 15 2
      app/[locale]/layout.tsx
  2. 3 1
      middleware.ts
  3. 1 1
      package.json
  4. 43 0
      public/manifest.json
  5. 43 0
      public/sw.js
  6. 20 0
      src/components/pwa/ServiceWorkerRegistration.tsx

+ 15 - 2
app/[locale]/layout.tsx

@@ -13,6 +13,7 @@ import { Header } from "@/features/layout/Header";
 import { Footer } from "@/features/layout/Footer";
 import { TailwindIndicator } from "@/components/utils/TailwindIndicator";
 import { NextTopLoader } from "@/components/ui/next-top-loader";
+import { ServiceWorkerRegistration } from "@/components/pwa/ServiceWorkerRegistration";
 
 import { Providers } from "./providers";
 
@@ -116,6 +117,17 @@ export default async function RootLayout({ params, children }: RootLayoutProps)
           <meta charSet="UTF-8" />
           <meta content="width=device-width, initial-scale=1, maximum-scale=1 viewport-fit=cover" name="viewport" />
 
+          {/* PWA Meta Tags */}
+          <meta content="yes" name="apple-mobile-web-app-capable" />
+          <meta content="default" name="apple-mobile-web-app-status-bar-style" />
+          <meta content="Workout Cool" name="apple-mobile-web-app-title" />
+          <meta content="yes" name="mobile-web-app-capable" />
+          <meta content="#FF5722" name="msapplication-TileColor" />
+          <meta content="/android-chrome-192x192.png" name="msapplication-TileImage" />
+          
+          {/* PWA Manifest */}
+          <link href="/manifest.json" rel="manifest" />
+
           {/* eslint-disable-next-line @next/next/no-page-custom-font */}
           <link as="style" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="preload" />
 
@@ -123,8 +135,8 @@ export default async function RootLayout({ params, children }: RootLayoutProps)
           <link href="https://www.workout.cool/fr" hrefLang="fr" rel="alternate" />
           <link href="https://www.workout.cool/en" hrefLang="en" rel="alternate" />
 
-          {/* Balise theme-color unique, synchronisée dynamiquement */}
-          <meta content="#f3f4f6" name="theme-color" />
+          {/* Theme color for PWA */}
+          <meta content="#FF5722" name="theme-color" />
 
           {/* TODO: maybe add some ads ? */}
           <noscript>
@@ -150,6 +162,7 @@ export default async function RootLayout({ params, children }: RootLayoutProps)
           suppressHydrationWarning
         >
           <Providers locale={locale}>
+            <ServiceWorkerRegistration />
             <WorkoutSessionsSynchronizer />
             <ThemeSynchronizer />
             <NextTopLoader color="#FF5722" delay={100} showSpinner={false} />

+ 3 - 1
middleware.ts

@@ -27,5 +27,7 @@ export async function middleware(request: NextRequest) {
 }
 
 export const config = {
-  matcher: ["/((?!api|static|_next|manifest.json|scripts/pixel.js|favicon.ico|robots.txt|service-worker\\.js|images|icons|sitemap.xml).*)"],
+  matcher: [
+    "/((?!api|static|_next|manifest.json|scripts/pixel.js|favicon.ico|robots.txt|service-worker\\.js|sw.js|apple-touch-icon.png|android-chrome-.*\\.png|images|icons|sitemap.xml).*)",
+  ],
 };

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "workoutcool",
-  "version": "0.1.0",
+  "version": "1.1.0",
   "private": true,
   "scripts": {
     "dev": "next dev --turbopack",

+ 43 - 0
public/manifest.json

@@ -0,0 +1,43 @@
+{
+  "background_color": "#f3f4f6",
+  "categories": ["health", "fitness", "sports"],
+  "description": "Your personal workout companion - track workouts, build routines, and stay motivated",
+  "display": "standalone",
+  "icons": [
+    {
+      "src": "/images/favicon-16x16.png",
+      "sizes": "16x16",
+      "type": "image/png"
+    },
+    {
+      "src": "/images/favicon-32x32.png",
+      "sizes": "32x32",
+      "type": "image/png"
+    },
+    {
+      "src": "/apple-touch-icon.png",
+      "sizes": "180x180",
+      "type": "image/png",
+      "purpose": "any maskable"
+    },
+    {
+      "src": "/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "any maskable"
+    },
+    {
+      "src": "/android-chrome-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "any maskable"
+    }
+  ],
+  "lang": "en",
+  "name": "Workout Cool",
+  "orientation": "portrait",
+  "scope": "/",
+  "short_name": "Workout Cool",
+  "start_url": "/",
+  "theme_color": "#FF5722"
+}

+ 43 - 0
public/sw.js

@@ -0,0 +1,43 @@
+const CACHE_NAME = "workout-cool-v1";
+const urlsToCache = [
+  "/",
+  "/manifest.json",
+  "/images/favicon-32x32.png",
+  "/images/favicon-16x16.png",
+  "/apple-touch-icon.png",
+  "/android-chrome-192x192.png",
+  "/android-chrome-512x512.png",
+];
+
+// Install event - cache resources
+self.addEventListener("install", (event) => {
+  event.waitUntil(
+    caches.open(CACHE_NAME).then((cache) => {
+      return cache.addAll(urlsToCache);
+    }),
+  );
+});
+
+// Fetch event - serve from cache when offline
+self.addEventListener("fetch", (event) => {
+  event.respondWith(
+    caches.match(event.request).then((response) => {
+      return response || fetch(event.request);
+    }),
+  );
+});
+
+// Activate event - clean up old caches
+self.addEventListener("activate", (event) => {
+  event.waitUntil(
+    caches.keys().then((cacheNames) => {
+      return Promise.all(
+        cacheNames.map((cacheName) => {
+          if (cacheName !== CACHE_NAME) {
+            return caches.delete(cacheName);
+          }
+        }),
+      );
+    }),
+  );
+});

+ 20 - 0
src/components/pwa/ServiceWorkerRegistration.tsx

@@ -0,0 +1,20 @@
+"use client";
+
+import { useEffect } from "react";
+
+export function ServiceWorkerRegistration() {
+  useEffect(() => {
+    if ("serviceWorker" in navigator) {
+      navigator.serviceWorker
+        .register("/sw.js")
+        .then((registration) => {
+          console.log("SW registered: ", registration);
+        })
+        .catch((registrationError) => {
+          console.log("SW registration failed: ", registrationError);
+        });
+    }
+  }, []);
+
+  return null;
+}