mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement PWA installation prompt and banner.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user