diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e92089d..26b2369 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,16 +1,38 @@ + + + -{@render children()} +
+ {@render children()} +
+ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b93e64b..4644b69 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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([]); let loading = $state(true); - let error = $state(null); - let filter = $state('all'); + let loadError = $state(null); + let filter = $state<'all' | 'in_progress' | 'success' | 'error'>('all'); let eventSource = $state(null); let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected'); let lastPing = $state(null); let hasAttemptedAutoSubscribe = $state(false); - let notificationViewModel = $state(null); + + // Screen router + let screen = $state<'home' | 'addurl' | 'notifications'>('home'); + // Recipe detail sheet + let selectedItem = $state(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 { + const g: Record = { + '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; + } - InstaRecipe Queue Dashboard - + InstaChef + -
- -
-

Recipe Queue Dashboard

-

Monitor your Instagram recipe extractions in real-time

-
+
+ + {#if screen === 'home'} +
+ (screen = 'notifications')} + /> - -
-
- -
- - -
- - - -
- - - {#if items.length > 0} - - - - - - {/if} -
- - - {#if loading} -
-
- Loading queue items... -
- {/if} - - - {#if error} -
-
- - - - Error loading queue: {error} -
-
- {/if} - - - {#if !loading && filteredItems.length === 0} -
-
- - - -
-

No queue items

-

- {#if filter === 'all'} - Start by sharing an Instagram recipe or adding a URL manually - {:else} - No items match the selected filter - {/if} -

- - - - - Add Recipe URL - -
- {:else} -
- {#each filteredItems as item (item.id)} - retryItem(item.id)} - onRemove={() => removeItem(item.id)} - onClearHighlight={clearHighlight} + {#if loading} +
+
+
+ {:else if loadError} +
Failed to load queue: {loadError}
+ {:else if items.length === 0} + (screen = 'addurl')} + {showHowTo} + onDismissHowTo={() => (showHowTo = false)} /> - {/each} + {:else} + + {#if cooking && filter !== 'success' && filter !== 'error'} + (selectedItem = cooking)} /> + {/if} + + +
+ {#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} + + {/each} +
+ + +
+ {#each (['In line', 'Today', 'Yesterday', 'Earlier'] as const) as g} + {#if groups[g]?.length} + {g} + {#each groups[g] as it (it.id)} + (selectedItem = it)} + onRetry={retryItem} + /> + {/each} + {/if} + {/each} +
+ {/if} + + +
+ +
+
+ + + {:else if screen === 'addurl'} +
+ (screen = 'home')} + onSubmit={submitUrl} + /> +
+ + + {:else if screen === 'notifications'} +
+ (screen = 'home')} + sseConnected={connectionStatus === 'connected'} + {sseLastPing} + />
{/if} - -
- -
- - -
-
- - - - -
-
-
-
-
+ + {#if selectedItem} + (selectedItem = null)} + onRetry={(id) => { retryItem(id); selectedItem = null; }} + /> + {/if}
+ + diff --git a/src/routes/components/AddUrlScreen.svelte b/src/routes/components/AddUrlScreen.svelte new file mode 100644 index 0000000..4f63281 --- /dev/null +++ b/src/routes/components/AddUrlScreen.svelte @@ -0,0 +1,276 @@ + + +
+ +
+ +
Add a recipe
+
+
+ +
+
STEP 01 · PASTE
+

+ Drop the
+ Instagram
+ link here. +

+

+ Reels, posts, carousels — anything with a recipe in the caption. We'll cook it down into + something searchable. +

+ + +
+
+ + (focused = true)} + onblur={() => (focused = false)} + class="url-input" + /> + {#if url} + + {/if} +
+
+ + + + + +
+
+ Pro move +
+
+ Add InstaChef to your share sheet and you can send recipes here straight from the Instagram + app — no copy-paste required. +
+
+
+ + +
+ +
+
+ + diff --git a/src/routes/components/Chip.svelte b/src/routes/components/Chip.svelte new file mode 100644 index 0000000..c52db2b --- /dev/null +++ b/src/routes/components/Chip.svelte @@ -0,0 +1,38 @@ + + + + {@render children()} + + + diff --git a/src/routes/components/CookingHero.svelte b/src/routes/components/CookingHero.svelte new file mode 100644 index 0000000..8b8829a --- /dev/null +++ b/src/routes/components/CookingHero.svelte @@ -0,0 +1,162 @@ + + + + + diff --git a/src/routes/components/CookingPot.svelte b/src/routes/components/CookingPot.svelte new file mode 100644 index 0000000..ce0611e --- /dev/null +++ b/src/routes/components/CookingPot.svelte @@ -0,0 +1,76 @@ + + +
+ + {#if animate && (phase === 'prepping' || phase === 'simmering')} + {#each { length: steamCount } as _, i} +
+ {/each} + {/if} + + + + + + + + + + + + + + {#if animate && phase !== 'prepping'} + + + + {/if} + +
+ + diff --git a/src/routes/components/EmptyState.svelte b/src/routes/components/EmptyState.svelte new file mode 100644 index 0000000..1da30aa --- /dev/null +++ b/src/routes/components/EmptyState.svelte @@ -0,0 +1,204 @@ + + +
+ +
+
+ +
+
Empty kitchen
+

+ Cook
anything
from a link. +

+

+ Paste an Instagram recipe and we'll turn it into a real, savable recipe in Tandoor. +

+ +
+ + + {#if showHowTo} +
+ +
How it works
+ + {#each [ + { + icon: PhasePrepping, + n: '01', + t: 'Prepping', + d: 'We grab the post, caption, and any tagged ingredients off Instagram.' + }, + { + icon: PhaseSimmering, + n: '02', + t: 'Simmering', + d: 'An LLM reads the caption and turns it into structured ingredients + steps.' + }, + { + icon: PhasePlating, + n: '03', + t: 'Plating', + d: 'The finished recipe lands in your Tandoor cookbook, ready to cook.' + } + ] as step, i} +
0}> +
+ +
+
+
STEP {step.n}
+
{step.t}
+
{step.d}
+
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/routes/components/InstallPrompt.svelte b/src/routes/components/InstallPrompt.svelte index 69365d8..c5a8d98 100644 --- a/src/routes/components/InstallPrompt.svelte +++ b/src/routes/components/InstallPrompt.svelte @@ -2,6 +2,11 @@ import { onMount } from 'svelte'; import { browser } from '$app/environment'; import { pwaInstallManager } from '$lib/client/PWAInstallManager'; + import Chip from './Chip.svelte'; + import Bell from './ic/Bell.svelte'; + import Share from './ic/Share.svelte'; + import Download from './ic/Download.svelte'; + import Spark from './ic/Spark.svelte'; let showPrompt = $state(false); let showFallback = $state(false); @@ -11,36 +16,18 @@ let unsubscribe: (() => void) | null = null; onMount(() => { - // Don't show if already dismissed or in standalone mode - if (pwaInstallManager.isDismissed() || pwaInstallManager.isStandalone()) { - return; - } + if (pwaInstallManager.isDismissed() || pwaInstallManager.isStandalone()) return; - // Listen for install state changes unsubscribe = pwaInstallManager.onInstallStateChange((installable) => { canInstall = installable; - - // Show prompt after user engagement and delay if (installable && userEngaged && !pwaInstallManager.isDismissed()) { - setTimeout(() => { - showPrompt = true; - }, 2000); + setTimeout(() => { showPrompt = true; }, 2000); } else if (!installable && userEngaged && !pwaInstallManager.isStandalone() && !pwaInstallManager.isDismissed()) { - // Show fallback instructions for browsers without beforeinstallprompt - setTimeout(() => { - showFallback = true; - }, 5000); + setTimeout(() => { showFallback = true; }, 5000); } }); - // Detect user engagement - const detectEngagement = () => { - userEngaged = true; - document.removeEventListener('scroll', detectEngagement); - document.removeEventListener('click', detectEngagement); - document.removeEventListener('keydown', detectEngagement); - }; - + const detectEngagement = () => { userEngaged = true; }; document.addEventListener('scroll', detectEngagement, { once: true }); document.addEventListener('click', detectEngagement, { once: true }); document.addEventListener('keydown', detectEngagement, { once: true }); @@ -55,18 +42,12 @@ async function handleInstall() { installing = true; - try { const result = await pwaInstallManager.showInstallPrompt(); - - if (result === 'accepted') { - showPrompt = false; - showFallback = false; - } else if (result === 'dismissed') { - handleDismiss(); - } - } catch (error) { - console.error('Install failed:', error); + if (result === 'accepted') { showPrompt = false; showFallback = false; } + else if (result === 'dismissed') handleDismiss(); + } catch (e) { + console.error('Install failed:', e); } finally { installing = false; } @@ -79,171 +60,201 @@ } - + {#if showPrompt && canInstall} -
-
-
-
-
- -
-
- - - -
-
+ + +
+
e.stopPropagation()}> + +
- -
-

Install InstaRecipe

-

- Get faster access and offline support. Works like a native app! -

-
-
- - -
- - - +
+ +
+ InstaChef +
+ + INSTALL + +
Put InstaChef on your home screen
- -
-
- - - - Offline access -
-
- - - - Push notifications -
-
- - - - Faster loading -
-
- - - - Home screen access -
+ +
+ {#each [ + { Icon: Share, color: 'var(--purple)', t: 'Share-sheet target', d: 'Send links from Instagram in one tap.' }, + { Icon: Bell, color: 'var(--pink)', t: 'Push when ready', d: 'Buzz on save, retry, or fail.' }, + { Icon: Download, color: 'var(--orange)', t: 'Works offline', d: 'Browse saved recipes anywhere.' }, + { Icon: Spark, color: 'var(--yellow)', t: 'Faster too', d: 'Launches like a native app.' }, + ] as f} +
+ +
{f.t}
+
{f.d}
+
+ {/each}
+ + + +
{/if} - -{#if showFallback && !canInstall && !pwaInstallManager.isStandalone()} -
-
-
-
- - - -
+ +{#if showFallback && !canInstall && browser && !pwaInstallManager.isStandalone()} +
+
+
+
Install InstaChef
+
{pwaInstallManager.getInstallInstructions()}
-
-

Install InstaRecipe

-

- {pwaInstallManager.getInstallInstructions()} -

- - - {#if pwaInstallManager.getBrowserName() === 'safari'} -
- - - - Use the Share button -
- {:else} -
- - - - Look for install button -
- {/if} -
- +
{/if} \ No newline at end of file diff --git a/src/routes/components/NotificationsScreen.svelte b/src/routes/components/NotificationsScreen.svelte new file mode 100644 index 0000000..4b30eec --- /dev/null +++ b/src/routes/components/NotificationsScreen.svelte @@ -0,0 +1,322 @@ + + +
+ +
+ +
Notifications
+
+
+ +
+ +
+
+ {#if enabled} + + {:else} + + {/if} +
+ +
+ + {enabled ? '● LIVE' : 'OFF'} + +

+ {enabled ? 'Push is on.' : "Get a ping when it's ready."} +

+

+ {#if enabled} + We'll buzz you the moment a recipe is saved, fails, or needs a retry. + {:else} + Don't miss a plate. Allow notifications and we'll buzz you when a recipe is saved. + {/if} +

+ + {#if notifState.error} +
{notifState.error}
+ {/if} + + {#if notifState.permission === 'denied'} +
+ Notifications are blocked. Enable them in your browser settings. +
+ {:else} + + {/if} +
+
+ + +
+
You'll hear about
+ {#each [ + { color: 'var(--status-success)', title: 'Recipe saved', desc: 'Tap the notification to open it in Tandoor.' }, + { color: 'var(--status-error)', title: 'Extraction failed', desc: 'Retry directly from the notification.' }, + { color: 'var(--orange)', title: 'Long-running parses', desc: 'When a post is taking longer than usual.' } + ] as row, i} +
0}> +
+
+
{row.title}
+
{row.desc}
+
+
+ {/each} +
+ + +
+
Live queue
+
+ + {sseConnected ? 'SSE connected' : 'SSE disconnected'} + + {#if sseLastPing} + {sseLastPing} + {/if} +
+
+
+
+ + diff --git a/src/routes/components/PhaseTrack.svelte b/src/routes/components/PhaseTrack.svelte new file mode 100644 index 0000000..a196818 --- /dev/null +++ b/src/routes/components/PhaseTrack.svelte @@ -0,0 +1,87 @@ + + +
+ {#each phases as p, i} + {@const state = i < current ? 'done' : i === current ? 'active' : 'idle'} +
+
+ {p.label} +
+ {#if i < phases.length - 1} +
+ {/if} + {/each} +
+ + diff --git a/src/routes/components/RecipeSheet.svelte b/src/routes/components/RecipeSheet.svelte new file mode 100644 index 0000000..f0845d0 --- /dev/null +++ b/src/routes/components/RecipeSheet.svelte @@ -0,0 +1,435 @@ + + +{#if item} + + + +{/if} + + diff --git a/src/routes/components/RecipeThumb.svelte b/src/routes/components/RecipeThumb.svelte new file mode 100644 index 0000000..22ef42e --- /dev/null +++ b/src/routes/components/RecipeThumb.svelte @@ -0,0 +1,86 @@ + + +
+
+
+
+ {#if emoji} + {emoji} + {:else} + + + + {/if} +
+
+ + diff --git a/src/routes/components/SectionHead.svelte b/src/routes/components/SectionHead.svelte new file mode 100644 index 0000000..c294158 --- /dev/null +++ b/src/routes/components/SectionHead.svelte @@ -0,0 +1,31 @@ + + +
+ {#if emoji}{emoji}{/if} + {@render children()} +
+ + diff --git a/src/routes/components/TimelineRow.svelte b/src/routes/components/TimelineRow.svelte new file mode 100644 index 0000000..f9d176f --- /dev/null +++ b/src/routes/components/TimelineRow.svelte @@ -0,0 +1,193 @@ + + + + +
e.key === 'Enter' && onTap?.()}> + +
+ {#if isPending} +
+ #{queuePosition ?? 1} +
+ {:else} + + {/if} + {#if isError} +
!
+ {:else if isSuccess} +
+ +
+ {/if} +
+ + +
+
+ {recipe?.name || (isPending ? 'Waiting in line…' : 'Untitled recipe')} +
+
+ {username(item.url)} + · + {relTime(item.createdAt)} +
+ {#if isError && item.error} +
{item.error.message?.slice(0, 60)}…
+ {/if} +
+ + +
+ {#if isError} + + {:else} + + {/if} +
+
+ + diff --git a/src/routes/components/TopBar.svelte b/src/routes/components/TopBar.svelte new file mode 100644 index 0000000..d0cc7af --- /dev/null +++ b/src/routes/components/TopBar.svelte @@ -0,0 +1,106 @@ + + +
+
+ +
+
InstaChef
+
+ + LIVE · {count} RECIPES +
+
+
+
+ +
+
+ + diff --git a/src/routes/components/ic/Bell.svelte b/src/routes/components/ic/Bell.svelte new file mode 100644 index 0000000..030d72b --- /dev/null +++ b/src/routes/components/ic/Bell.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/routes/components/ic/BellOff.svelte b/src/routes/components/ic/BellOff.svelte new file mode 100644 index 0000000..f347f46 --- /dev/null +++ b/src/routes/components/ic/BellOff.svelte @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/routes/components/ic/Check.svelte b/src/routes/components/ic/Check.svelte new file mode 100644 index 0000000..255ec4f --- /dev/null +++ b/src/routes/components/ic/Check.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/components/ic/Chevron.svelte b/src/routes/components/ic/Chevron.svelte new file mode 100644 index 0000000..1de18ca --- /dev/null +++ b/src/routes/components/ic/Chevron.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/src/routes/components/ic/Clipboard.svelte b/src/routes/components/ic/Clipboard.svelte new file mode 100644 index 0000000..9d6bc16 --- /dev/null +++ b/src/routes/components/ic/Clipboard.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/routes/components/ic/Close.svelte b/src/routes/components/ic/Close.svelte new file mode 100644 index 0000000..b9a44cc --- /dev/null +++ b/src/routes/components/ic/Close.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/components/ic/Download.svelte b/src/routes/components/ic/Download.svelte new file mode 100644 index 0000000..70d0a57 --- /dev/null +++ b/src/routes/components/ic/Download.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/routes/components/ic/External.svelte b/src/routes/components/ic/External.svelte new file mode 100644 index 0000000..d182166 --- /dev/null +++ b/src/routes/components/ic/External.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/components/ic/Filter.svelte b/src/routes/components/ic/Filter.svelte new file mode 100644 index 0000000..f7a8029 --- /dev/null +++ b/src/routes/components/ic/Filter.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/components/ic/Link.svelte b/src/routes/components/ic/Link.svelte new file mode 100644 index 0000000..7dcffa0 --- /dev/null +++ b/src/routes/components/ic/Link.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/routes/components/ic/PhasePlating.svelte b/src/routes/components/ic/PhasePlating.svelte new file mode 100644 index 0000000..69961ba --- /dev/null +++ b/src/routes/components/ic/PhasePlating.svelte @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/routes/components/ic/PhasePrepping.svelte b/src/routes/components/ic/PhasePrepping.svelte new file mode 100644 index 0000000..f329132 --- /dev/null +++ b/src/routes/components/ic/PhasePrepping.svelte @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/routes/components/ic/PhaseSimmering.svelte b/src/routes/components/ic/PhaseSimmering.svelte new file mode 100644 index 0000000..1cffac7 --- /dev/null +++ b/src/routes/components/ic/PhaseSimmering.svelte @@ -0,0 +1,20 @@ + + + {#if animate} + + + + {/if} + + + + {#if animate} + + + + {/if} + + + diff --git a/src/routes/components/ic/Plus.svelte b/src/routes/components/ic/Plus.svelte new file mode 100644 index 0000000..aa0c0bb --- /dev/null +++ b/src/routes/components/ic/Plus.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/components/ic/Retry.svelte b/src/routes/components/ic/Retry.svelte new file mode 100644 index 0000000..8b33ea8 --- /dev/null +++ b/src/routes/components/ic/Retry.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/components/ic/Search.svelte b/src/routes/components/ic/Search.svelte new file mode 100644 index 0000000..8477f92 --- /dev/null +++ b/src/routes/components/ic/Search.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/components/ic/Settings.svelte b/src/routes/components/ic/Settings.svelte new file mode 100644 index 0000000..0bdd3ea --- /dev/null +++ b/src/routes/components/ic/Settings.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/routes/components/ic/Share.svelte b/src/routes/components/ic/Share.svelte new file mode 100644 index 0000000..4ca3544 --- /dev/null +++ b/src/routes/components/ic/Share.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/src/routes/components/ic/Spark.svelte b/src/routes/components/ic/Spark.svelte new file mode 100644 index 0000000..5282867 --- /dev/null +++ b/src/routes/components/ic/Spark.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/components/ic/Trash.svelte b/src/routes/components/ic/Trash.svelte new file mode 100644 index 0000000..790dbae --- /dev/null +++ b/src/routes/components/ic/Trash.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/src/routes/layout.css b/src/routes/layout.css index d4b5078..ebe4b9e 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -1 +1,171 @@ -@import 'tailwindcss'; +/* ─── InstaChef design system ─────────────────────────────────────────────── */ + +/* Brand + shared tokens (theme-independent) */ +:root { + --grad-1: #833AB4; + --grad-2: #C13584; + --grad-3: #E1306C; + --grad-4: #FD7E14; + --grad-5: #FCAF45; + --brand-gradient: linear-gradient(135deg, var(--grad-1) 0%, var(--grad-3) 45%, var(--grad-4) 75%, var(--grad-5) 100%); + --brand-gradient-soft: linear-gradient(135deg, #FCE9F3 0%, #FFEAD8 100%); + + --purple: #833AB4; + --pink: #E1306C; + --orange: #FD7E14; + --yellow: #FCAF45; + --berry: #C13584; + + --status-pending: #FCAF45; + --status-success: #2EA56A; + --status-error: #E64B4B; + + --font-display: "Lilita One", "Caprasimo", system-ui, sans-serif; + --font-body: "DM Sans", -apple-system, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; +} + +/* Light theme */ +.ic-root[data-theme="light"] { + --bg: #FFF8F5; + --bg-tint: #FFEFE4; + --surface: #FFFFFF; + --surface-2: #FDF1EC; + --surface-3: #F7E5DC; + --ink: #1A0B1F; + --ink-2: #3A2A40; + --muted: #7A6B7D; + --muted-2: #A8989C; + --border: rgba(26, 11, 31, 0.08); + --border-strong: rgba(26, 11, 31, 0.14); + --shadow-sm: 0 1px 2px rgba(26, 11, 31, 0.04), 0 2px 8px rgba(26, 11, 31, 0.04); + --shadow-md: 0 4px 12px rgba(26, 11, 31, 0.06), 0 12px 32px rgba(26, 11, 31, 0.05); + --shadow-lg: 0 12px 28px rgba(193, 53, 132, 0.18), 0 24px 60px rgba(131, 58, 180, 0.15); +} + +/* Dark theme */ +.ic-root[data-theme="dark"] { + --bg: #110510; + --bg-tint: #1A0A1F; + --surface: #1F0F24; + --surface-2: #2A1730; + --surface-3: #371E3E; + --ink: #FCEFE5; + --ink-2: #E0D2DA; + --muted: #A38FA8; + --muted-2: #6E5A73; + --border: rgba(255, 235, 245, 0.08); + --border-strong: rgba(255, 235, 245, 0.16); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 12px 28px rgba(225, 48, 108, 0.35), 0 24px 60px rgba(131, 58, 180, 0.3); +} + +/* ─── Base reset ──────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; +} + +.ic-root { + font-family: var(--font-body); + color: var(--ink); + background: var(--bg); + -webkit-font-smoothing: antialiased; + min-height: 100dvh; +} + +/* ─── Utility classes ─────────────────────────────────────────────────────── */ +.ic-display { + font-family: var(--font-display); + font-weight: 400; + letter-spacing: -0.005em; + line-height: 1; +} + +.ic-grad-text { + background: var(--brand-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.ic-scroll { + scrollbar-width: none; + -ms-overflow-style: none; +} +.ic-scroll::-webkit-scrollbar { display: none; } + +button.ic-btn { + background: none; + border: 0; + padding: 0; + cursor: pointer; + font: inherit; + color: inherit; + -webkit-tap-highlight-color: transparent; +} + +/* ─── Animations ──────────────────────────────────────────────────────────── */ +@keyframes ic-steam { + 0% { transform: translateY(0) translateX(0) scale(0.6); opacity: 0; } + 25% { opacity: 0.85; } + 100% { transform: translateY(-44px) translateX(var(--drift, 4px)) scale(1.4); opacity: 0; } +} +.ic-steam { + animation: ic-steam 2.4s ease-out infinite; + animation-delay: var(--delay, 0s); +} + +@keyframes ic-bubble { + 0%, 100% { transform: translateY(0) scale(1); } + 50% { transform: translateY(-2px) scale(1.05); } +} +.ic-bubble { animation: ic-bubble 1.6s ease-in-out infinite; } + +@keyframes ic-chop { + 0%, 100% { transform: rotate(-30deg) translateY(0); } + 50% { transform: rotate(-8deg) translateY(-2px); } +} +.ic-chop { animation: ic-chop 0.9s cubic-bezier(.7,0,.3,1) infinite; transform-origin: bottom right; } + +@keyframes ic-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(200%); } +} +.ic-shimmer { animation: ic-shimmer 2s ease-in-out infinite; } + +@keyframes ic-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(0.95); } +} +.ic-pulse { animation: ic-pulse 1.4s ease-in-out infinite; } + +@keyframes ic-slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} +.ic-slide-up { animation: ic-slide-up 0.32s cubic-bezier(.2,.7,.2,1); } + +@keyframes ic-fade { + from { opacity: 0; } + to { opacity: 1; } +} +.ic-fade { animation: ic-fade 0.24s ease-out; } + +@keyframes ic-pop { + 0% { transform: scale(0.6); opacity: 0; } + 60% { transform: scale(1.08); opacity: 1; } + 100% { transform: scale(1); } +} +.ic-pop { animation: ic-pop 0.4s cubic-bezier(.2,1.4,.4,1); } + +@keyframes ic-live { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.ic-live { animation: ic-live 1.4s ease-in-out infinite; } + diff --git a/src/routes/share/+page.svelte b/src/routes/share/+page.svelte index 0136613..b4d18f9 100644 --- a/src/routes/share/+page.svelte +++ b/src/routes/share/+page.svelte @@ -2,13 +2,11 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { onMount } from 'svelte'; - import UrlInputSection from './components/UrlInputSection.svelte'; + import AddUrlScreen from '../components/AddUrlScreen.svelte'; - let status = $state('idle'); - let logs = $state([]); + let status = $state<'idle' | 'enqueuing' | 'success' | 'error'>('idle'); // URL param parsing for Share Target - // Instagram typically shares text that contains the URL, so we might need to parse it out let sharedText = $derived($page.url.searchParams.get('text') || ''); let sharedUrl = $derived($page.url.searchParams.get('url') || ''); @@ -17,32 +15,27 @@ return match ? match[0] : null; } - let targetUrl = $derived(sharedUrl || extractUrl(sharedText)); + let targetUrl = $derived(sharedUrl || extractUrl(sharedText) || ''); // Track if we've already auto-processed to prevent duplicate processing let hasAutoProcessed = $state(false); // Auto-process URL if provided via share target - // Use onMount instead of $effect for side effects (SvelteKit best practice) onMount(() => { if (targetUrl && status === 'idle' && !hasAutoProcessed) { hasAutoProcessed = true; - process(); + process(targetUrl); } }); - async function process(url?: string) { - const urlToProcess = url || targetUrl; - if (!urlToProcess) return; - + async function process(url: string) { + if (!url) return; status = 'enqueuing'; - logs = [...logs, '🚀 Enqueuing extraction from: ' + urlToProcess]; try { - // Enqueue URL for background processing const response = await fetch('/api/queue', { method: 'POST', - body: JSON.stringify({ url: urlToProcess }), + body: JSON.stringify({ url }), headers: { 'Content-Type': 'application/json' } }); @@ -52,88 +45,100 @@ } const queueItem = await response.json(); - logs = [...logs, `✅ URL enqueued successfully with ID: ${queueItem.id}`]; - logs = [...logs, '🔄 Redirecting to queue dashboard...']; + status = 'success'; - // Small delay to show the success message setTimeout(() => { - // Redirect to homepage (queue dashboard) with the queue item ID highlighted goto(`/?highlight=${queueItem.id}`); - }, 1500); - + }, 800); } catch (e) { status = 'error'; - const errorMessage = e instanceof Error ? e.message : 'Unknown error'; - logs = [...logs, `❌ Error: ${errorMessage}`]; + console.error('Failed to enqueue:', e); } } - - function retry() { - status = 'idle'; - logs = [...logs, 'Retrying...']; - process(); - } - Share to InstaRecipe - + Add Recipe — InstaChef + -
-
-

Share to InstaRecipe

-

- {#if targetUrl} - Processing your shared recipe... +{#if status === 'enqueuing' || status === 'success'} + +

+
+ {#if status === 'enqueuing'} +
+
Adding to queue…
{:else} - Paste an Instagram recipe URL to extract it +
+
Added! Redirecting…
{/if} -

+
{targetUrl}
+
+{:else} + goto('/')} + onSubmit={process} + /> +{/if} - {#if !targetUrl} - - {:else} - -
-
-

Processing URL:

-

{targetUrl}

- - {#if status === 'enqueuing'} -
-
- Enqueuing for processing... -
- {:else if status === 'error'} -
- ❌ Error occurred -
- - {:else} -
✅ Ready to process
- {/if} -
-
- {/if} - - - {#if logs.length > 0} -
-
-

Process Log:

-
- {#each logs as log} -
{log}
- {/each} -
-
-
- {/if} -
\ No newline at end of file + \ No newline at end of file diff --git a/static/icon-256.png b/static/icon-256.png new file mode 100644 index 0000000..7f30850 Binary files /dev/null and b/static/icon-256.png differ diff --git a/static/manifest.json b/static/manifest.json index c830e97..cb682a7 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -4,8 +4,8 @@ "start_url": "/", "scope": "/", "display": "standalone", - "theme_color": "#ffffff", - "background_color": "#ffffff", + "theme_color": "#FFF8F5", + "background_color": "#FFF8F5", "icons": [ { "src": "/favicon.png",