-
-
Notifications
You must be signed in to change notification settings - Fork 7
Full PWA Support Implementation #436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import type { MetadataRoute } from 'next' | ||
|
|
||
| export default function manifest(): MetadataRoute.Manifest { | ||
| return { | ||
| name: 'QCX - AI-powered language to Maps', | ||
| short_name: 'QCX', | ||
| description: 'A minimalistic AI-powered tool that transforms language into interactive maps.', | ||
| start_url: '/?utm_source=pwa', | ||
| display: 'standalone', | ||
| background_color: '#171717', | ||
| theme_color: '#171717', | ||
| icons: [ | ||
| { | ||
| src: '/icon-192.png', | ||
| sizes: '192x192', | ||
| type: 'image/png', | ||
| purpose: 'any' | ||
| }, | ||
| { | ||
| src: '/icon-512.png', | ||
| sizes: '512x512', | ||
| type: 'image/png', | ||
| purpose: 'any' | ||
| }, | ||
| { | ||
| src: '/icon-maskable.png', | ||
| sizes: '1024x1024', | ||
| type: 'image/png', | ||
| purpose: 'maskable' | ||
| } | ||
| ], | ||
| categories: ['search', 'ai', 'productivity', 'maps'] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| 'use client' | ||
|
|
||
| import React, { useEffect, useState } from 'react' | ||
| import { X, Share } from 'lucide-react' | ||
|
|
||
| export function IosInstallPrompt() { | ||
| const [showPrompt, setShowPrompt] = useState(false) | ||
|
|
||
| useEffect(() => { | ||
| if (typeof window === 'undefined') return | ||
|
|
||
| // Detect iOS | ||
| const userAgent = window.navigator.userAgent | ||
| const isIosDevice = /iPad|iPhone|iPod/.test(userAgent) | ||
|
|
||
| // Detect if already in standalone mode (installed) | ||
| const isInStandaloneMode = | ||
| (window.navigator as any).standalone || | ||
| window.matchMedia('(display-mode: standalone)').matches | ||
|
|
||
| // Check if prompt was dismissed recently | ||
| const isPromptDismissed = localStorage.getItem('iosPwaPromptDismissed') | ||
|
|
||
| if (isIosDevice && !isInStandaloneMode && !isPromptDismissed) { | ||
| setShowPrompt(true) | ||
| } | ||
| }, []) | ||
|
|
||
| const handleDismiss = () => { | ||
| setShowPrompt(false) | ||
| localStorage.setItem('iosPwaPromptDismissed', 'true') | ||
| } | ||
|
Comment on lines
+21
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The iOS prompt uses a permanent dismissal flag ( SuggestionStore a timestamp and only suppress for a period (or version the key): const KEY = 'iosPwaPromptDismissedAt'
const dismissedAt = Number(localStorage.getItem(KEY) ?? '0')
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000
const dismissedRecently = dismissedAt && Date.now() - dismissedAt < THIRTY_DAYS
// ...
localStorage.setItem(KEY, String(Date.now()))Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.
Comment on lines
+12
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The iOS install prompt is dismissed permanently ( SuggestionUse a TTL-based dismissal and broaden iPadOS detection. Example: const DISMISS_KEY = 'iosPwaPromptDismissedAt'
const DISMISS_TTL_MS = 1000 * 60 * 60 * 24 * 14 // 14 days
const dismissedAt = Number(localStorage.getItem(DISMISS_KEY) ?? '0')
const isPromptDismissed = Date.now() - dismissedAt < DISMISS_TTL_MS
const ua = navigator.userAgent
const isIphoneIpod = /iPhone|iPod/.test(ua)
const isIpad = /iPad/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
const isIosDevice = isIphoneIpod || isIpad
// on dismiss:
localStorage.setItem(DISMISS_KEY, String(Date.now()))Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion. |
||
|
|
||
| if (!showPrompt) return null | ||
|
|
||
| return ( | ||
| <div className="fixed bottom-4 left-4 right-4 z-[100] md:left-auto md:right-4 md:max-w-sm animate-in fade-in slide-in-from-bottom-4 duration-500"> | ||
| <div className="relative overflow-hidden rounded-2xl bg-background border shadow-2xl p-4 pr-10"> | ||
| <button | ||
| onClick={handleDismiss} | ||
| className="absolute right-2 top-2 p-1 text-muted-foreground hover:text-foreground rounded-full hover:bg-muted transition-colors" | ||
| aria-label="Dismiss" | ||
| > | ||
| <X className="h-4 w-4" /> | ||
| </button> | ||
|
|
||
| <div className="flex items-start gap-4"> | ||
| <div className="flex-shrink-0 bg-primary/10 p-2 rounded-xl"> | ||
| <div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center text-primary-foreground font-bold"> | ||
| Q | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex-grow pt-1"> | ||
| <h3 className="text-sm font-semibold leading-none mb-1">Install QCX</h3> | ||
| <p className="text-xs text-muted-foreground leading-snug"> | ||
| Add this app to your home screen for a better experience. | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="mt-4 pt-3 border-t flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground"> | ||
| <span>Tap the Share icon</span> | ||
| <div className="bg-muted p-1 rounded-md"> | ||
| <Share className="h-3 w-3" /> | ||
| </div> | ||
| <span>then scroll down and select</span> | ||
| <span className="font-semibold text-foreground">"Add to Home Screen"</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect } from 'react' | ||
|
|
||
| export function SwRegistration() { | ||
| useEffect(() => { | ||
| if (typeof window !== 'undefined' && 'serviceWorker' in navigator) { | ||
| window.addEventListener('load', () => { | ||
| navigator.serviceWorker | ||
| .register('/sw.js') | ||
| .then((registration) => { | ||
| console.log('SW registered: ', registration) | ||
| }) | ||
| .catch((registrationError) => { | ||
| console.log('SW registration failed: ', registrationError) | ||
| }) | ||
| }) | ||
| } | ||
| }, []) | ||
|
Comment on lines
+5
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
SuggestionConsider registering immediately (or via a stable handler) and adding cleanup to avoid leaking listeners, and gate logs behind Example: useEffect(() => {
if (!('serviceWorker' in navigator)) return
const register = () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// optionally report
})
}
if (document.readyState === 'complete') {
register()
return
}
window.addEventListener('load', register, { once: true })
return () => window.removeEventListener('load', register)
}, [])Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion. |
||
|
|
||
| return null | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| // public/sw.js | ||
| // Basic service worker to satisfy PWA install criteria | ||
| self.addEventListener('install', (event) => { | ||
| self.skipWaiting(); | ||
| }); | ||
|
|
||
| self.addEventListener('activate', (event) => { | ||
| event.waitUntil(clients.claim()); | ||
| }); | ||
|
|
||
| self.addEventListener('fetch', (event) => { | ||
| // A fetch handler is required for the PWA to be installable on Chrome. | ||
| // This is a "no-op" fetch handler. | ||
| }); | ||
|
Comment on lines
+1
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The service worker has a SuggestionEither remove the Example pass-through: self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request))
})If you want minimal offline support, add a cache in Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
metadata.icons.appleis set to/icon-192.png, but iOS home screen icons are typically expected at specific sizes (e.g., 180x180). Using only a 192px icon may lead to suboptimal rendering on iOS. If you want good iOS support, consider adding anapple-touch-iconat 180x180 (and optionally additional sizes) and reference it via metadata.Suggestion
Add a dedicated
public/apple-touch-icon.png(180x180) and set:Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.