feat(RECIPE-0009): complete iteration 0 — deduplication, notifications, UI improvements
This commit is contained in:
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user