Files
insta-recipe/src/routes/+page.svelte
Giancarmine Salucci e49dbfae41 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.
2025-12-22 15:18:03 +01:00

344 lines
11 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { onMount, onDestroy } from 'svelte';
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);
let error = $state<string | null>(null);
let filter = $state<string>('all');
let eventSource = $state<EventSource | null>(null);
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
let lastPing = $state<string | null>(null);
// Get highlighted item ID from URL params (when redirected from Share page)
let highlightId = $derived($page.url.searchParams.get('highlight'));
// Available filters - derived to be reactive
let filters = $derived([
{ id: 'all', name: 'All Items', count: items.length },
{ id: 'pending', name: 'Pending', count: items.filter(item => item.status === 'pending').length },
{ id: 'in_progress', name: 'Processing', count: items.filter(item => item.status === 'in_progress').length },
{ id: 'success', name: 'Complete', count: items.filter(item => item.status === 'success').length },
{ id: 'error', name: 'Failed', count: items.filter(item => item.status === 'error' || item.status === 'unhealthy').length }
]);
// Filter items based on selected filter
// Using $derived.by to execute the function and derive the result array
let filteredItems = $derived.by(() => {
if (filter === 'all') return items;
if (filter === 'error') return items.filter(item => item.status === 'error' || item.status === 'unhealthy');
return items.filter(item => item.status === filter);
});
onMount(async () => {
await loadQueueItems();
if (browser) {
startSSEConnection();
}
});
onDestroy(() => {
if (eventSource) {
console.log('[SSE] Closing connection on component destroy');
eventSource.close();
connectionStatus = 'disconnected';
}
});
async function loadQueueItems() {
try {
loading = true;
error = null;
const response = await fetch('/api/queue');
if (!response.ok) {
throw new Error('Failed to load queue items');
}
const data = await response.json();
items = data.items || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
console.error('Failed to load queue items:', e);
} finally {
loading = false;
}
}
function startSSEConnection() {
if (!browser) {
console.error('Cannot start SSE connection on server side');
return; // Guard: EventSource is browser-only API
}
connectionStatus = 'connecting';
console.log('[SSE] Connecting to queue stream...');
try {
eventSource = new EventSource('/api/queue/stream');
eventSource.addEventListener('open', () => {
console.log('[SSE] Connection opened');
connectionStatus = 'connected';
});
eventSource.addEventListener('connection', (event) => {
const data = JSON.parse(event.data);
console.log('[SSE] Connection confirmed:', data.message);
connectionStatus = 'connected';
});
eventSource.addEventListener('queue-update', (event) => {
const update: QueueStatusUpdate = JSON.parse(event.data);
updateQueueItem(update);
});
eventSource.addEventListener('error', (event) => {
console.error('[SSE] Connection error:', event);
connectionStatus = 'disconnected';
// Attempt to reconnect after 5 seconds
setTimeout(() => {
// EventSource.CLOSED = 2 (use numeric constant for SSR safety)
if (eventSource?.readyState === 2) {
console.log('[SSE] Attempting reconnection...');
startSSEConnection();
}
}, 5000);
});
eventSource.addEventListener('ping', (event) => {
// Keep-alive ping, update last ping timestamp
const data = JSON.parse(event.data);
lastPing = data.timestamp;
console.log('[SSE] Keep-alive ping received at:', data.timestamp);
});
} catch (e) {
console.error('[SSE] Failed to start SSE connection:', e);
connectionStatus = 'disconnected';
}
}
function updateQueueItem(update: QueueStatusUpdate) {
// Find and update the item in the list
const itemIndex = items.findIndex(item => item.id === update.itemId);
if (itemIndex >= 0) {
// Update existing item
items[itemIndex] = {
...items[itemIndex],
status: update.status,
phases: update.progress || items[itemIndex].phases,
results: update.results || items[itemIndex].results,
error: update.error || items[itemIndex].error,
updatedAt: update.timestamp
};
} else {
// New item - fetch full details from API
fetchQueueItem(update.itemId);
}
// Trigger reactivity
items = [...items];
}
async function fetchQueueItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}`);
if (response.ok) {
const item = await response.json();
items = [item, ...items]; // Add to top of list
}
} catch (e) {
console.error('Failed to fetch queue item:', e);
}
}
async function retryItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}/retry`, {
method: 'POST'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to retry item');
}
// Item will be updated via SSE
console.log('Retry initiated for item:', id);
} catch (e) {
console.error('Failed to retry item:', e);
// Could show a toast notification here
}
}
async function removeItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to remove item');
}
// Item will be removed from local state via SSE update
// but remove immediately for better UX
items = items.filter(item => item.id !== id);
console.log('Item removed successfully:', id);
} catch (e) {
console.error('Failed to remove item:', e);
// Fallback: remove from local state anyway
items = items.filter(item => item.id !== id);
}
}
function clearHighlight() {
// Remove highlight parameter from URL without navigation
const url = new URL(window.location.href);
url.searchParams.delete('highlight');
replaceState(url, {});
}
</script>
<svelte:head>
<title>InstaRecipe Queue Dashboard</title>
<meta name="description" content="Monitor your recipe extraction queue in real-time" />
</svelte:head>
<div class="mx-auto p-6 max-w-6xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Recipe Queue Dashboard</h1>
<p class="text-gray-600">Monitor your Instagram recipe extractions in real-time</p>
</div>
<!-- Action Bar -->
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
<!-- Filter Tabs -->
<div class="flex flex-wrap gap-2">
{#each filters as filterOption}
<button
onclick={() => filter = filterOption.id}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {filter === filterOption.id
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
{filterOption.name}
{#if filterOption.count > 0}
<span class="ml-1 {filter === filterOption.id ? 'text-blue-100' : 'text-gray-500'}">
({filterOption.count})
</span>
{/if}
</button>
{/each}
</div>
<!-- Refresh Button -->
<button
onclick={loadQueueItems}
disabled={loading}
class="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
>
<svg class="w-4 h-4 {loading ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span>Refresh</span>
</button>
</div>
<!-- Loading State -->
{#if loading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Loading queue items...</span>
</div>
{/if}
<!-- Error State -->
{#if error}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<span class="text-red-800">Error loading queue: {error}</span>
</div>
</div>
{/if}
<!-- Queue Items -->
{#if !loading && filteredItems.length === 0}
<div class="text-center py-12">
<div class="text-gray-400 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No queue items</h3>
<p class="text-gray-600 mb-6">
{#if filter === 'all'}
Start by sharing an Instagram recipe or adding a URL manually
{:else}
No items match the selected filter
{/if}
</p>
<a
href="/share"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add Recipe URL
</a>
</div>
{:else}
<div class="space-y-4">
{#each filteredItems as item (item.id)}
<QueueItemCard
{item}
highlighted={item.id === highlightId}
onRetry={() => retryItem(item.id)}
onRemove={() => removeItem(item.id)}
onClearHighlight={clearHighlight}
/>
{/each}
</div>
{/if}
<!-- Notification Settings - Always visible -->
<div class="mt-8">
<NotificationSettings />
</div>
<!-- Connection Status -->
<div class="fixed bottom-4 right-4">
<div class="flex items-center space-x-2 px-3 py-2 bg-white border rounded-lg shadow-sm text-sm">
<div class="w-2 h-2 rounded-full {
connectionStatus === 'connected' ? 'bg-green-400' :
connectionStatus === 'connecting' ? 'bg-yellow-400' :
'bg-red-400'
}"></div>
<span class="text-gray-600">
{connectionStatus === 'connected' ? 'Live updates' :
connectionStatus === 'connecting' ? 'Connecting...' :
'Disconnected'}
</span>
{#if lastPing}
<span class="text-xs text-gray-400">
({new Date(lastPing).toLocaleTimeString()})
</span>
{/if}
</div>
</div>
</div>