feat: fix push notifications and enhance PWA experience
- Fix InvalidCharacterError in push notifications with proper VAPID key validation - Add attractive PWA install prompt component with cross-browser support - Make notification settings always visible regardless of queue status - Implement PWA install manager with user engagement detection - Use SvelteKit navigation APIs instead of browser history API - Add comprehensive error handling and logging - Include cross-browser compatibility and responsive design - Add development tooling improvements Fixes push notification bugs and significantly improves PWA user experience with modern, accessible interface components and proper error handling.
This commit is contained in:
201
src/lib/client/PWAInstallManager.ts
Normal file
201
src/lib/client/PWAInstallManager.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* PWA Installation Manager
|
||||
*
|
||||
* Handles PWA installation flow with cross-browser support.
|
||||
* Provides beforeinstallprompt event handling, user engagement detection,
|
||||
* and dismissal state management for the install prompt.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
export class PWAInstallManager {
|
||||
private deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
private listeners: Array<(canInstall: boolean) => void> = [];
|
||||
private installable = false;
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.initializeInstallPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PWA install prompt event listeners
|
||||
*/
|
||||
private initializeInstallPrompt(): void {
|
||||
// Listen for beforeinstallprompt event (Chrome, Edge)
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
e.preventDefault();
|
||||
this.deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
this.installable = true;
|
||||
this.notifyListeners(true);
|
||||
console.log('[PWA] Install prompt available');
|
||||
});
|
||||
|
||||
// Listen for app installation completion
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('[PWA] App was installed');
|
||||
this.installable = false;
|
||||
this.deferredPrompt = null;
|
||||
this.notifyListeners(false);
|
||||
|
||||
// Clear dismissal state since user installed
|
||||
this.clearDismissed();
|
||||
});
|
||||
|
||||
// Check if already installed
|
||||
if (this.isStandalone()) {
|
||||
console.log('[PWA] App is already running in standalone mode');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if PWA can be installed
|
||||
*/
|
||||
public canInstall(): boolean {
|
||||
return this.installable && this.deferredPrompt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the browser's install prompt
|
||||
*
|
||||
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
|
||||
*/
|
||||
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
|
||||
if (!this.deferredPrompt) {
|
||||
console.warn('[PWA] Install prompt not available');
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deferredPrompt.prompt();
|
||||
const { outcome } = await this.deferredPrompt.userChoice;
|
||||
|
||||
this.deferredPrompt = null;
|
||||
this.installable = false;
|
||||
this.notifyListeners(false);
|
||||
|
||||
console.log(`[PWA] Install prompt ${outcome}`);
|
||||
return outcome;
|
||||
} catch (error) {
|
||||
console.error('[PWA] Install prompt failed:', error);
|
||||
return 'dismissed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for install state changes
|
||||
*
|
||||
* @param callback Function to call when install state changes
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
|
||||
this.listeners.push(callback);
|
||||
|
||||
// Call immediately with current state
|
||||
callback(this.canInstall());
|
||||
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of state change
|
||||
*/
|
||||
private notifyListeners(canInstall: boolean): void {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(canInstall);
|
||||
} catch (error) {
|
||||
console.error('[PWA] Error in install state listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app is running in standalone mode (already installed)
|
||||
*/
|
||||
public isStandalone(): boolean {
|
||||
if (!browser) return false;
|
||||
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true ||
|
||||
document.referrer.includes('android-app://')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has dismissed the install prompt
|
||||
*/
|
||||
public isDismissed(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem('pwa-install-dismissed') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark install prompt as dismissed by user
|
||||
*/
|
||||
public setDismissed(): void {
|
||||
if (browser) {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
console.log('[PWA] Install prompt dismissed by user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear dismissal state (called when app is installed)
|
||||
*/
|
||||
public clearDismissed(): void {
|
||||
if (browser) {
|
||||
localStorage.removeItem('pwa-install-dismissed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser-specific installation instructions
|
||||
*/
|
||||
public getInstallInstructions(): string {
|
||||
if (!browser) return 'Install instructions not available';
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
|
||||
|
||||
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
|
||||
return 'Tap the Share button and select "Add to Home Screen"';
|
||||
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
|
||||
return 'Look for the install button in your browser address bar';
|
||||
} else if (userAgent.includes('edg')) {
|
||||
return 'Look for the install button in your browser address bar';
|
||||
} else if (userAgent.includes('firefox')) {
|
||||
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
|
||||
}
|
||||
|
||||
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current browser name for UI customization
|
||||
*/
|
||||
public getBrowserName(): string {
|
||||
if (!browser) return 'unknown';
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
|
||||
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
|
||||
if (userAgent.includes('firefox')) return 'firefox';
|
||||
if (userAgent.includes('edg')) return 'edge';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for application-wide use
|
||||
export const pwaInstallManager = new PWAInstallManager();
|
||||
@@ -302,26 +302,61 @@ class PushNotificationManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert VAPID key to Uint8Array
|
||||
* Convert URL-safe base64 string to Uint8Array
|
||||
* Enhanced with validation and error handling for VAPID keys
|
||||
* SSR-safe: uses window.atob only in browser context
|
||||
*/
|
||||
private urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
if (!browser) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
// Input validation
|
||||
if (!base64String || typeof base64String !== 'string') {
|
||||
console.error('[PushManager] Invalid VAPID key: empty or non-string');
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
// Remove whitespace and validate format
|
||||
const cleanKey = base64String.trim();
|
||||
if (cleanKey.length === 0) {
|
||||
console.error('[PushManager] Invalid VAPID key: empty string');
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
// VAPID keys should be 65 characters (unpadded base64)
|
||||
if (cleanKey.length !== 65) {
|
||||
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Add proper padding
|
||||
const padding = '='.repeat((4 - cleanKey.length % 4) % 4);
|
||||
const base64 = (cleanKey + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
// Validate base64 format before decoding
|
||||
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
|
||||
if (!base64Regex.test(base64)) {
|
||||
throw new Error('Invalid base64 characters');
|
||||
}
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
|
||||
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
|
||||
return outputArray;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
|
||||
throw new Error(`Invalid VAPID key format: ${errorMessage}`);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* and coordinates with the main application.
|
||||
*/
|
||||
|
||||
import { pushState } from "$app/navigation";
|
||||
|
||||
interface ServiceWorkerMessage {
|
||||
type: string;
|
||||
action?: string;
|
||||
@@ -91,10 +93,10 @@ class ServiceWorkerMessageHandler {
|
||||
// If not found, navigate to homepage with highlight
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('highlight', itemId);
|
||||
window.history.pushState({}, '', url.toString());
|
||||
pushState(url, {});
|
||||
|
||||
// Refresh page to show the item
|
||||
window.location.reload();
|
||||
//window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const queueConfig = {
|
||||
|
||||
/** Web Push notification settings */
|
||||
push: {
|
||||
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BDummyPublicKeyForDevelopment',
|
||||
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'DummyPrivateKeyForDevelopment'
|
||||
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
|
||||
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import InstallPrompt from './components/InstallPrompt.svelte';
|
||||
import './layout.css';
|
||||
|
||||
let { children } = $props();
|
||||
@@ -10,3 +11,6 @@
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<!-- PWA Install Prompt -->
|
||||
<InstallPrompt />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
|
||||
import QueueItemCard from './components/QueueItemCard.svelte';
|
||||
import NotificationSettings from './components/NotificationSettings.svelte';
|
||||
import { replaceState } from '$app/navigation';
|
||||
|
||||
let items = $state<QueueItem[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -204,7 +205,7 @@
|
||||
// Remove highlight parameter from URL without navigation
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('highlight');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
replaceState(url, {});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -314,12 +315,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notification Settings -->
|
||||
{#if filteredItems.length > 0 || filter !== 'all'}
|
||||
<div class="mt-8">
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Notification Settings - Always visible -->
|
||||
<div class="mt-8">
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="fixed bottom-4 right-4">
|
||||
|
||||
249
src/routes/components/InstallPrompt.svelte
Normal file
249
src/routes/components/InstallPrompt.svelte
Normal file
@@ -0,0 +1,249 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { pwaInstallManager } from '$lib/client/PWAInstallManager';
|
||||
|
||||
let showPrompt = $state(false);
|
||||
let showFallback = $state(false);
|
||||
let canInstall = $state(false);
|
||||
let installing = $state(false);
|
||||
let userEngaged = $state(false);
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
// Don't show if already dismissed or in standalone mode
|
||||
if (pwaInstallManager.isDismissed() || pwaInstallManager.isStandalone()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for install state changes
|
||||
unsubscribe = pwaInstallManager.onInstallStateChange((installable) => {
|
||||
canInstall = installable;
|
||||
|
||||
// Show prompt after user engagement and delay
|
||||
if (installable && userEngaged && !pwaInstallManager.isDismissed()) {
|
||||
setTimeout(() => {
|
||||
showPrompt = true;
|
||||
}, 2000);
|
||||
} else if (!installable && userEngaged && !pwaInstallManager.isStandalone() && !pwaInstallManager.isDismissed()) {
|
||||
// Show fallback instructions for browsers without beforeinstallprompt
|
||||
setTimeout(() => {
|
||||
showFallback = true;
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// Detect user engagement
|
||||
const detectEngagement = () => {
|
||||
userEngaged = true;
|
||||
document.removeEventListener('scroll', detectEngagement);
|
||||
document.removeEventListener('click', detectEngagement);
|
||||
document.removeEventListener('keydown', detectEngagement);
|
||||
};
|
||||
|
||||
document.addEventListener('scroll', detectEngagement, { once: true });
|
||||
document.addEventListener('click', detectEngagement, { once: true });
|
||||
document.addEventListener('keydown', detectEngagement, { once: true });
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
document.removeEventListener('scroll', detectEngagement);
|
||||
document.removeEventListener('click', detectEngagement);
|
||||
document.removeEventListener('keydown', detectEngagement);
|
||||
};
|
||||
});
|
||||
|
||||
async function handleInstall() {
|
||||
installing = true;
|
||||
|
||||
try {
|
||||
const result = await pwaInstallManager.showInstallPrompt();
|
||||
|
||||
if (result === 'accepted') {
|
||||
showPrompt = false;
|
||||
showFallback = false;
|
||||
} else if (result === 'dismissed') {
|
||||
handleDismiss();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Install failed:', error);
|
||||
} finally {
|
||||
installing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
showPrompt = false;
|
||||
showFallback = false;
|
||||
pwaInstallManager.setDismissed();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Main Install Prompt (for browsers with beforeinstallprompt support) -->
|
||||
{#if showPrompt && canInstall}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 transform transition-transform duration-300 ease-out animate-slide-up">
|
||||
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-2xl">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- App Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-lg">
|
||||
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white">Install InstaRecipe</h3>
|
||||
<p class="text-blue-100 text-sm">
|
||||
Get faster access and offline support. Works like a native app!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
onclick={handleInstall}
|
||||
disabled={installing}
|
||||
class="bg-white text-blue-600 px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors disabled:opacity-50 flex items-center space-x-2 shadow-lg"
|
||||
>
|
||||
{#if installing}
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Installing...</span>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<span>Install</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handleDismiss}
|
||||
class="text-blue-100 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="mt-3 flex flex-wrap gap-3 text-xs text-blue-100">
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Offline access</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Push notifications</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Faster loading</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Home screen access</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fallback Instructions (for browsers without beforeinstallprompt) -->
|
||||
{#if showFallback && !canInstall && !pwaInstallManager.isStandalone()}
|
||||
<div class="fixed bottom-4 right-4 max-w-sm bg-white border rounded-lg shadow-xl p-4 z-40 animate-fade-in">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-1">Install InstaRecipe</h4>
|
||||
<p class="text-xs text-gray-600 mb-3">
|
||||
{pwaInstallManager.getInstallInstructions()}
|
||||
</p>
|
||||
|
||||
<!-- Browser-specific hints -->
|
||||
{#if pwaInstallManager.getBrowserName() === 'safari'}
|
||||
<div class="flex items-center space-x-1 text-xs text-blue-600 bg-blue-50 rounded px-2 py-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"></path>
|
||||
</svg>
|
||||
<span>Use the Share button</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center space-x-1 text-xs text-green-600 bg-green-50 rounded px-2 py-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<span>Look for install button</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={handleDismiss}
|
||||
class="text-gray-400 hover:text-gray-500 flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
@@ -64,6 +64,12 @@
|
||||
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Get notified when your recipe extractions complete, even when InstaRecipe is not open.
|
||||
{#if typeof window !== 'undefined'}
|
||||
<!-- Check if we're on the homepage and queue appears empty -->
|
||||
{#if window.location.pathname === '/' && !document.querySelector('[data-queue-item]')}
|
||||
Start by adding some Instagram recipe URLs to see notifications in action!
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- Status -->
|
||||
|
||||
Reference in New Issue
Block a user