feat(RECIPE-0009): complete iteration 0 — deduplication, notifications, UI improvements

This commit is contained in:
Giancarmine Salucci
2026-02-18 06:00:48 +01:00
parent 40e3fb0c1b
commit dfca35bde2
12 changed files with 864 additions and 395 deletions

View File

@@ -6,6 +6,7 @@
import QueueItemCard from './components/QueueItemCard.svelte';
import NotificationSettings from './components/NotificationSettings.svelte';
import { replaceState } from '$app/navigation';
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
let items = $state<QueueItem[]>([]);
let loading = $state(true);
@@ -14,6 +15,7 @@
let eventSource = $state<EventSource | null>(null);
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
let lastPing = $state<string | null>(null);
let hasAttemptedAutoSubscribe = $state(false);
// Get highlighted item ID from URL params (when redirected from Share page)
let highlightId = $derived($page.url.searchParams.get('highlight'));
@@ -39,6 +41,7 @@
await loadQueueItems();
if (browser) {
startSSEConnection();
setupAutoSubscribe();
}
});
@@ -125,6 +128,41 @@
}
}
/**
* Setup automatic notification subscription on first user interaction
*
* Follows Web Push API best practices: subscription requires user gesture.
* Listens for first click/touch anywhere on page, checks if notifications
* are supported but not subscribed, then auto-subscribes.
*/
function setupAutoSubscribe() {
if (hasAttemptedAutoSubscribe) return;
const attemptSubscribe = async () => {
if (hasAttemptedAutoSubscribe) return;
hasAttemptedAutoSubscribe = true;
const state = pushNotificationManager.getState();
// Only auto-subscribe if:
// - Browser supports notifications
// - Permission is not denied
// - Not already subscribed
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
console.log('[HomePage] Auto-subscribing to notifications on first interaction');
await pushNotificationManager.subscribe();
}
// Remove listener after first attempt
document.removeEventListener('click', attemptSubscribe);
document.removeEventListener('touchstart', attemptSubscribe);
};
// Listen for first user interaction
document.addEventListener('click', attemptSubscribe, { once: true });
document.addEventListener('touchstart', attemptSubscribe, { once: true });
}
function updateQueueItem(update: QueueStatusUpdate) {
// Find and update the item in the list
const itemIndex = items.findIndex(item => item.id === update.itemId);
@@ -223,36 +261,46 @@
<!-- 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'}"
<div class="flex items-center gap-4 w-full sm:w-auto">
<!-- Filter Dropdown -->
<div class="flex items-center gap-2">
<label for="filter-select" class="text-sm font-medium text-gray-700">Filter:</label>
<select
id="filter-select"
bind:value={filter}
class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
>
{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}
{#each filters as filterOption}
<option value={filterOption.id}>
{filterOption.name} ({filterOption.count})
</option>
{/each}
</select>
</div>
<!-- Refresh Button (moved to same row) -->
<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>
<!-- 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"
<!-- Add Recipe Button (always visible) -->
<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 text-sm font-medium"
>
<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 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>
<span>Refresh</span>
</button>
Add Recipe URL
</a>
</div>
<!-- Loading State -->