- 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.
344 lines
11 KiB
Svelte
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>
|