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:
@@ -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