feat: Implement PWA installation prompt and banner.

This commit is contained in:
kuekhaoyang
2026-01-02 14:04:23 +08:00
parent e7a3671b8e
commit cb0dee9469
3 changed files with 171 additions and 0 deletions
+2
View File
@@ -5,6 +5,7 @@ import { ThemeProvider } from "@/components/ThemeProvider";
import { Analytics } from "@vercel/analytics/react";
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
import { PasswordGate } from "@/components/PasswordGate";
import { PWAInstallBanner } from "@/components/PWAInstallBanner";
import { siteConfig } from "@/lib/config/site-config";
@@ -46,6 +47,7 @@ export default function RootLayout({
suppressHydrationWarning
>
<ThemeProvider>
<PWAInstallBanner />
<PasswordGate hasEnvPassword={!!process.env.ACCESS_PASSWORD}>
{children}
</PasswordGate>
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useState, useEffect } from 'react';
import { usePWAInstall } from '@/lib/hooks/usePWAInstall';
import { X, Download, Share } from 'lucide-react';
import { Button } from '@/components/ui/Button';
export function PWAInstallBanner() {
const { isInstallable, isIOS, handleInstallClick } = usePWAInstall();
const [isVisible, setIsVisible] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
// Show banner after a short delay to not be too intrusive
if ((isInstallable || isIOS) && !isDismissed) {
const timer = setTimeout(() => setIsVisible(true), 2000);
return () => clearTimeout(timer);
} else {
setIsVisible(false);
}
}, [isInstallable, isIOS, isDismissed]);
const handleDismiss = () => {
setIsVisible(false);
setIsDismissed(true);
// Optionally save to localStorage to not show again for a while
localStorage.setItem('pwa-banner-dismissed', Date.now().toString());
};
useEffect(() => {
const dismissedAt = localStorage.getItem('pwa-banner-dismissed');
if (dismissedAt) {
const oneDay = 24 * 60 * 60 * 1000;
if (Date.now() - parseInt(dismissedAt) < oneDay) {
setIsDismissed(true);
}
}
}, []);
if (!isVisible) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[100] p-4 animate-in fade-in slide-in-from-top duration-500">
<div className="max-w-xl mx-auto backdrop-blur-xl bg-white/10 dark:bg-black/40 border border-white/20 dark:border-white/10 rounded-2xl shadow-2xl p-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center overflow-hidden flex-shrink-0">
<img src="/icon.png" alt="KVideo" className="w-full h-full object-cover" />
</div>
<div>
<h3 className="text-sm font-semibold text-white"> KVideo</h3>
<p className="text-xs text-white/60">
{isIOS ? '点击浏览器分享按钮,选择“添加到主屏幕”' : '安装到桌面,体验更流畅'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!isIOS && (
<Button
size="sm"
onClick={handleInstallClick}
className="bg-white text-black hover:bg-white/90 rounded-full px-4"
>
<Download className="w-3.5 h-3.5 mr-1.5" />
</Button>
)}
{isIOS && (
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-white/10">
<Share className="w-4 h-4 text-white" />
</div>
)}
<button
onClick={handleDismiss}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<X className="w-4 h-4 text-white/40" />
</button>
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
export function usePWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstallable, setIsInstallable] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
// Check if app is already installed/standalone
const checkStandalone = () => {
const isStandaloneMode = window.matchMedia('(display-mode: standalone)').matches
|| (window.navigator as any).standalone
|| document.referrer.includes('android-app://');
setIsStandalone(isStandaloneMode);
setIsInstalled(isStandaloneMode);
};
checkStandalone();
// Check if iOS
const checkIOS = () => {
const ua = window.navigator.userAgent.toLowerCase();
const isIOSDevice = /iphone|ipad|ipod/.test(ua);
setIsIOS(isIOSDevice);
};
checkIOS();
const handleBeforeInstallPrompt = (e: Event) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
// Stash the event so it can be triggered later.
setDeferredPrompt(e as BeforeInstallPromptEvent);
setIsInstallable(true);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setIsInstallable(false);
setDeferredPrompt(null);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
// Show the install prompt
deferredPrompt.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setIsInstallable(false);
setDeferredPrompt(null);
}
};
return {
isInstallable: isInstallable && !isInstalled,
isIOS: isIOS && !isStandalone,
isInstalled,
handleInstallClick,
};
}