feat(ui): implement InstaChef design system
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 38s
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 38s
- Replace Tailwind with IC CSS design tokens (purple/pink/orange brand gradient, Lilita One / DM Sans / JetBrains Mono fonts, light+dark theme via data-theme) - Add all SVG icon components (ic/Bell, BellOff, Check, Chevron, Clipboard, Close, Download, External, Filter, Link, Plus, Retry, Search, Settings, Share, Spark, Trash, PhasePrepping, PhaseSimmering, PhasePlating) - Add shared primitives: Chip, RecipeThumb (deterministic gradient swatch), CookingPot (animated SVG), PhaseTrack, SectionHead - Add TopBar with LIVE indicator and notification bell - Add CookingHero: animated hero card for in-progress items - Add TimelineRow: queue list row with status badges - Add EmptyState: gradient hero + dismissible How it works card - Add RecipeSheet: bottom-sheet detail overlay with phase progress - Add AddUrlScreen: full-page URL input with clipboard paste - Add NotificationsScreen: push toggle + SSE status - Rewrite +page.svelte: screen router (home/addurl/notifs) + RecipeSheet; preserves all SSE, retry, remove, filter, auto-subscribe logic - Rewrite share/+page.svelte: uses AddUrlScreen shell, preserves Share Target logic and auto-process on URL param - Rewrite InstallPrompt.svelte: InstallSheet bottom-sheet design, all PWA logic intact - Update manifest.json theme_color to #FFF8F5 - 282 unit tests passing (unchanged) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -3,42 +3,82 @@
|
||||
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';
|
||||
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
||||
import type { NotificationState } from '$lib/client/PushNotificationManager';
|
||||
|
||||
import TopBar from './components/TopBar.svelte';
|
||||
import CookingHero from './components/CookingHero.svelte';
|
||||
import TimelineRow from './components/TimelineRow.svelte';
|
||||
import EmptyState from './components/EmptyState.svelte';
|
||||
import RecipeSheet from './components/RecipeSheet.svelte';
|
||||
import SectionHead from './components/SectionHead.svelte';
|
||||
import AddUrlScreen from './components/AddUrlScreen.svelte';
|
||||
import NotificationsScreen from './components/NotificationsScreen.svelte';
|
||||
|
||||
// ── State ──────────────────────────────────────────────────
|
||||
let items = $state<QueueItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let filter = $state<string>('all');
|
||||
let loadError = $state<string | null>(null);
|
||||
let filter = $state<'all' | 'in_progress' | 'success' | 'error'>('all');
|
||||
let eventSource = $state<EventSource | null>(null);
|
||||
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
|
||||
let lastPing = $state<string | null>(null);
|
||||
let hasAttemptedAutoSubscribe = $state(false);
|
||||
let notificationViewModel = $state<NotificationState | null>(null);
|
||||
|
||||
// Screen router
|
||||
let screen = $state<'home' | 'addurl' | 'notifications'>('home');
|
||||
// Recipe detail sheet
|
||||
let selectedItem = $state<QueueItem | null>(null);
|
||||
// "How it works" dismissible card
|
||||
let showHowTo = $state(true);
|
||||
|
||||
// Get highlighted item ID from URL params (when redirected from Share page)
|
||||
let highlightId = $derived($page.url.searchParams.get('highlight'));
|
||||
const 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(() => {
|
||||
// ── Derived ────────────────────────────────────────────────
|
||||
const 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);
|
||||
if (filter === 'error') return items.filter((i) => i.status === 'error' || i.status === 'unhealthy');
|
||||
return items.filter((i) => i.status === filter);
|
||||
});
|
||||
|
||||
const sseLastPing = $derived(
|
||||
lastPing ? relTime(lastPing) + ' ago' : ''
|
||||
);
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (diff < 60) return Math.round(diff) + 's';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm';
|
||||
return Math.floor(diff / 3600) + 'h';
|
||||
}
|
||||
|
||||
// ── groupByDate ────────────────────────────────────────────
|
||||
type Group = 'Cooking now' | 'In line' | 'Today' | 'Yesterday' | 'Earlier';
|
||||
function groupByDate(list: QueueItem[]): Record<Group, QueueItem[]> {
|
||||
const g: Record<Group, QueueItem[]> = {
|
||||
'Cooking now': [],
|
||||
'In line': [],
|
||||
Today: [],
|
||||
Yesterday: [],
|
||||
Earlier: []
|
||||
};
|
||||
const now = Date.now();
|
||||
for (const it of list) {
|
||||
if (it.status === 'in_progress') { g['Cooking now'].push(it); continue; }
|
||||
if (it.status === 'pending') { g['In line'].push(it); continue; }
|
||||
const age = (now - new Date(it.createdAt).getTime()) / 1000;
|
||||
if (age < 86400) g['Today'].push(it);
|
||||
else if (age < 172800) g['Yesterday'].push(it);
|
||||
else g['Earlier'].push(it);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
const groups = $derived(groupByDate(filteredItems));
|
||||
const cooking = $derived(groups['Cooking now'][0] ?? null);
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────
|
||||
let unsubscribeNotifications: (() => void) | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
@@ -46,384 +86,374 @@
|
||||
if (browser) {
|
||||
startSSEConnection();
|
||||
setupAutoSubscribe();
|
||||
unsubscribeNotifications = pushNotificationManager.onStateChange((newState) => {
|
||||
notificationViewModel = newState;
|
||||
});
|
||||
unsubscribeNotifications = pushNotificationManager.onStateChange(() => {});
|
||||
|
||||
// Open RecipeSheet for highlighted item
|
||||
if (highlightId) {
|
||||
const found = items.find((i) => i.id === highlightId);
|
||||
if (found) { selectedItem = found; clearHighlight(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (eventSource) {
|
||||
console.log('[SSE] Closing connection on component destroy');
|
||||
eventSource.close();
|
||||
connectionStatus = 'disconnected';
|
||||
}
|
||||
// Add notification state cleanup
|
||||
eventSource?.close();
|
||||
connectionStatus = 'disconnected';
|
||||
unsubscribeNotifications?.();
|
||||
});
|
||||
|
||||
// ── Data fetching ──────────────────────────────────────────
|
||||
async function loadQueueItems() {
|
||||
try {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
loadError = null;
|
||||
const response = await fetch('/api/queue');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load queue items');
|
||||
}
|
||||
|
||||
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';
|
||||
loadError = 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
|
||||
async function submitUrl(url: string) {
|
||||
try {
|
||||
const response = await fetch('/api/queue', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.message || 'Failed to enqueue URL');
|
||||
}
|
||||
const queueItem = await response.json();
|
||||
// Item will arrive via SSE, but add immediately for UX
|
||||
items = [queueItem, ...items];
|
||||
screen = 'home';
|
||||
// Show the new item in RecipeSheet
|
||||
selectedItem = queueItem;
|
||||
} catch (e) {
|
||||
console.error('Failed to submit URL:', e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── SSE ────────────────────────────────────────────────────
|
||||
function startSSEConnection() {
|
||||
if (!browser) return;
|
||||
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('open', () => { connectionStatus = 'connected'; });
|
||||
eventSource.addEventListener('connection', () => { connectionStatus = 'connected'; });
|
||||
eventSource.addEventListener('queue-update', (event) => {
|
||||
const update: QueueStatusUpdate = JSON.parse(event.data);
|
||||
updateQueueItem(update);
|
||||
updateQueueItem(JSON.parse(event.data) as QueueStatusUpdate);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
console.error('[SSE] Connection error:', event);
|
||||
eventSource.addEventListener('error', () => {
|
||||
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();
|
||||
}
|
||||
if (eventSource?.readyState === 2) 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);
|
||||
lastPing = JSON.parse(event.data).timestamp;
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('[SSE] Failed to start SSE connection:', e);
|
||||
console.error('[SSE] Failed to start:', e);
|
||||
connectionStatus = 'disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 () => {
|
||||
const attempt = 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 });
|
||||
document.addEventListener('click', attempt, { once: true });
|
||||
document.addEventListener('touchstart', attempt, { once: true });
|
||||
}
|
||||
|
||||
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],
|
||||
const idx = items.findIndex((i) => i.id === update.itemId);
|
||||
if (idx >= 0) {
|
||||
items[idx] = {
|
||||
...items[idx],
|
||||
status: update.status,
|
||||
phases: update.progress || items[itemIndex].phases,
|
||||
results: update.results || items[itemIndex].results,
|
||||
error: update.error || items[itemIndex].error,
|
||||
phases: update.progress || items[idx].phases,
|
||||
results: update.results || items[idx].results,
|
||||
error: update.error || items[idx].error,
|
||||
updatedAt: update.timestamp
|
||||
};
|
||||
// Keep selectedItem in sync
|
||||
if (selectedItem?.id === update.itemId) selectedItem = items[idx];
|
||||
} 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
|
||||
}
|
||||
if (response.ok) items = [await response.json(), ...items];
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch queue item:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────
|
||||
async function retryItem(id: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/queue/${id}/retry`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
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');
|
||||
const err = await response.json();
|
||||
throw new Error(err.message || 'Failed to retry');
|
||||
}
|
||||
|
||||
// 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);
|
||||
await fetch(`/api/queue/${id}`, { method: 'DELETE' });
|
||||
} catch (e) {
|
||||
console.error('Failed to remove item:', e);
|
||||
// Fallback: remove from local state anyway
|
||||
items = items.filter(item => item.id !== id);
|
||||
} finally {
|
||||
items = items.filter((i) => i.id !== id);
|
||||
if (selectedItem?.id === id) selectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
// Remove highlight parameter from URL without navigation
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('highlight');
|
||||
replaceState(url, {});
|
||||
}
|
||||
|
||||
// Queue positions for pending items
|
||||
function queuePos(item: QueueItem): number {
|
||||
return items.filter((i) => i.status === 'pending').indexOf(item) + 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>InstaRecipe Queue Dashboard</title>
|
||||
<meta name="description" content="Monitor your recipe extraction queue in real-time" />
|
||||
<title>InstaChef</title>
|
||||
<meta name="description" content="Cook anything from an Instagram link." />
|
||||
</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>
|
||||
<div class="app-root">
|
||||
<!-- ── Home screen ────────────────────────────────────────── -->
|
||||
{#if screen === 'home'}
|
||||
<div class="ic-scroll home-scroll">
|
||||
<TopBar
|
||||
count={items.length}
|
||||
notifCount={0}
|
||||
onNotifications={() => (screen = 'notifications')}
|
||||
/>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||
<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"
|
||||
>
|
||||
{#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}
|
||||
title="Refresh queue"
|
||||
aria-label="Refresh queue"
|
||||
class="flex items-center p-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 {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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Recipe Button (icon-only, visible when items exist) -->
|
||||
{#if items.length > 0}
|
||||
<a
|
||||
href="/share"
|
||||
title="Add recipe URL"
|
||||
aria-label="Add recipe URL"
|
||||
class="inline-flex items-center p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</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}
|
||||
{#if loading}
|
||||
<div class="loading-wrap">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="err-banner">Failed to load queue: {loadError}</div>
|
||||
{:else if items.length === 0}
|
||||
<EmptyState
|
||||
onAdd={() => (screen = 'addurl')}
|
||||
{showHowTo}
|
||||
onDismissHowTo={() => (showHowTo = false)}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Cooking hero -->
|
||||
{#if cooking && filter !== 'success' && filter !== 'error'}
|
||||
<CookingHero item={cooking} onTap={() => (selectedItem = cooking)} />
|
||||
{/if}
|
||||
|
||||
<!-- Filter chips -->
|
||||
<div class="ic-scroll filter-row">
|
||||
{#each [
|
||||
{ id: 'all', label: 'All', count: items.length },
|
||||
{ id: 'in_progress', label: 'Cooking', count: items.filter((i) => i.status === 'in_progress').length },
|
||||
{ id: 'success', label: 'Saved', count: items.filter((i) => i.status === 'success').length },
|
||||
{ id: 'error', label: 'Failed', count: items.filter((i) => i.status === 'error' || i.status === 'unhealthy').length }
|
||||
] as f}
|
||||
<button
|
||||
class="ic-btn-reset filter-chip"
|
||||
class:active={filter === f.id}
|
||||
onclick={() => (filter = f.id as typeof filter)}
|
||||
>
|
||||
{f.label}
|
||||
<span class="chip-count">{f.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Timeline groups -->
|
||||
<div class="timeline">
|
||||
{#each (['In line', 'Today', 'Yesterday', 'Earlier'] as const) as g}
|
||||
{#if groups[g]?.length}
|
||||
<SectionHead>{g}</SectionHead>
|
||||
{#each groups[g] as it (it.id)}
|
||||
<TimelineRow
|
||||
item={it}
|
||||
queuePosition={queuePos(it)}
|
||||
onTap={() => (selectedItem = it)}
|
||||
onRetry={retryItem}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sticky add button -->
|
||||
<div class="add-fab-wrap">
|
||||
<button class="ic-btn-reset add-fab" onclick={() => (screen = 'addurl')}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M9 4v9a4 4 0 004 4h8M9 8h4M5 20h8" />
|
||||
</svg>
|
||||
Paste Instagram link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add URL screen ────────────────────────────────────── -->
|
||||
{:else if screen === 'addurl'}
|
||||
<div class="ic-scroll screen-scroll">
|
||||
<AddUrlScreen
|
||||
onBack={() => (screen = 'home')}
|
||||
onSubmit={submitUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Notifications screen ──────────────────────────────── -->
|
||||
{:else if screen === 'notifications'}
|
||||
<div class="ic-scroll screen-scroll">
|
||||
<NotificationsScreen
|
||||
onBack={() => (screen = 'home')}
|
||||
sseConnected={connectionStatus === 'connected'}
|
||||
{sseLastPing}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notification Settings - Always visible -->
|
||||
<div class="mt-8" data-notification-settings>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
|
||||
<!-- Footer Status Bar (icons only) -->
|
||||
<div class="fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg z-50">
|
||||
<div class="mx-auto max-w-6xl px-6 py-3 flex items-center justify-between">
|
||||
<!-- Notification Status Icon (left) -->
|
||||
<button
|
||||
onclick={() => {
|
||||
// Scroll to NotificationSettings component
|
||||
document.querySelector('[data-notification-settings]')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
title={notificationViewModel?.subscribed ? 'Notifications enabled' : notificationViewModel?.supported ? 'Notifications disabled' : 'Notifications not supported'}
|
||||
aria-label="Notification status"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{#if !notificationViewModel?.supported || notificationViewModel?.permission === 'denied'}
|
||||
<!-- Not supported / denied - bell with slash -->
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728"></path>
|
||||
</svg>
|
||||
{:else if notificationViewModel?.subscribed}
|
||||
<!-- Enabled - bell icon (green) -->
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Disabled - bell icon (gray) -->
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Live Update Indicator (right) -->
|
||||
<div
|
||||
title={connectionStatus === 'connected' ? 'Live updates active' : connectionStatus === 'connecting' ? 'Connecting to live updates...' : 'Live updates disconnected'}
|
||||
aria-label="Live update status"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full {
|
||||
connectionStatus === 'connected' ? 'bg-green-600' :
|
||||
connectionStatus === 'connecting' ? 'bg-yellow-600' :
|
||||
'bg-red-600'
|
||||
}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Recipe sheet overlay ─────────────────────────────── -->
|
||||
{#if selectedItem}
|
||||
<RecipeSheet
|
||||
item={selectedItem}
|
||||
onClose={() => (selectedItem = null)}
|
||||
onRetry={(id) => { retryItem(id); selectedItem = null; }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
.home-scroll,
|
||||
.screen-scroll {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--pink);
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.err-banner {
|
||||
margin: 20px 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: #ffe9e9;
|
||||
border: 1px solid #f8c2c2;
|
||||
color: #7e1717;
|
||||
font-size: 14px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 20px 20px 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.filter-chip {
|
||||
padding: 8px 14px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.filter-chip.active {
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
.chip-count {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.filter-chip.active .chip-count {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.timeline {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
.add-fab-wrap {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 16px 34px;
|
||||
background: linear-gradient(to top, var(--bg) 55%, color-mix(in srgb, var(--bg) 80%, transparent));
|
||||
pointer-events: none;
|
||||
z-index: 40;
|
||||
}
|
||||
.add-fab {
|
||||
width: 100%;
|
||||
pointer-events: auto;
|
||||
background: var(--brand-gradient);
|
||||
color: #fff;
|
||||
padding: 16px 20px;
|
||||
border-radius: 99px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user