feat(ui): implement InstaChef design system
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:
Giancarmine Salucci
2026-05-12 22:02:47 +02:00
parent 0b9f598c7d
commit 573cf49ac5
39 changed files with 2982 additions and 569 deletions

View File

@@ -1,16 +1,38 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import InstallPrompt from './components/InstallPrompt.svelte';
import { onMount } from 'svelte';
import './layout.css';
let { children } = $props();
onMount(() => {
const root = document.getElementById('ic-root');
if (!root) return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
root.setAttribute('data-theme', mq.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => {
root.setAttribute('data-theme', e.matches ? 'dark' : 'light');
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Lilita+One&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
{@render children()}
<div class="ic-root" data-theme="light" id="ic-root">
{@render children()}
</div>
<!-- PWA Install Prompt -->
<InstallPrompt />

View File

@@ -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();
eventSource?.close();
connectionStatus = 'disconnected';
}
// Add notification state cleanup
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 class="loading-wrap">
<div class="spinner"></div>
</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 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)}
/>
{:else}
No items match the selected filter
<!-- Cooking hero -->
{#if cooking && filter !== 'success' && filter !== 'error'}
<CookingHero item={cooking} onTap={() => (selectedItem = cooking)} />
{/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"
<!-- 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)}
>
<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>
{f.label}
<span class="chip-count">{f.count}</span>
</button>
{/each}
</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}
<!-- 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}
<!-- 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>
<!-- 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>
{: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}
Paste Instagram link
</button>
</div>
</div>
<!-- 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>
<!-- ── 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}
<!-- ── 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>

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import Chevron from './ic/Chevron.svelte';
import Link from './ic/Link.svelte';
import Close from './ic/Close.svelte';
import Clipboard from './ic/Clipboard.svelte';
import Spark from './ic/Spark.svelte';
interface Props {
onBack?: () => void;
onSubmit?: (url: string) => void;
initialUrl?: string;
}
let { onBack, onSubmit, initialUrl = '' }: Props = $props();
let url = $state(initialUrl);
let focused = $state(false);
let pasting = $state(false);
const valid = $derived(/https?:\/\/(www\.)?instagram\.com\//.test(url));
async function handlePaste() {
pasting = true;
try {
const text = await navigator.clipboard?.readText();
if (text) url = text;
} catch {
// clipboard access denied — do nothing
} finally {
setTimeout(() => (pasting = false), 200);
}
}
</script>
<div class="screen">
<!-- Back bar -->
<div class="back-bar">
<button class="ic-btn-reset back-btn" onclick={onBack} aria-label="Back">
<Chevron size={18} dir="left" color="var(--ink)" />
</button>
<div class="screen-title">Add a recipe</div>
<div style="width: 40px;"></div>
</div>
<div class="content">
<div class="step-label">STEP 01 · PASTE</div>
<h1 class="ic-display headline">
Drop the<br />
<span class="ic-grad-text">Instagram</span><br />
link here.
</h1>
<p class="sub-text">
Reels, posts, carousels — anything with a recipe in the caption. We'll cook it down into
something searchable.
</p>
<!-- URL input -->
<div class="input-ring" class:focused>
<div class="input-inner">
<Link size={18} color="var(--muted)" />
<input
type="url"
placeholder="instagram.com/reel/..."
bind:value={url}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
class="url-input"
/>
{#if url}
<button class="ic-btn-reset clear-btn" onclick={() => (url = '')} aria-label="Clear">
<Close size={12} color="var(--muted)" />
</button>
{/if}
</div>
</div>
<!-- Paste button -->
<button class="ic-btn-reset paste-btn" onclick={handlePaste} style="opacity: {pasting ? 0.6 : 1}">
<Clipboard size={16} color="var(--ink-2)" /> Paste from clipboard
</button>
<!-- Pro tip -->
<div class="hint-card">
<div class="hint-title">
<Spark size={12} color="var(--purple)" /> Pro move
</div>
<div class="hint-body">
Add InstaChef to your share sheet and you can send recipes here straight from the Instagram
app — no copy-paste required.
</div>
</div>
</div>
<!-- Submit button -->
<div class="submit-wrap">
<button
class="ic-btn-reset submit-btn"
onclick={() => valid && onSubmit?.(url)}
disabled={!valid}
style="
background: {valid ? 'var(--brand-gradient)' : 'var(--surface-3)'};
color: {valid ? '#fff' : 'var(--muted-2)'};
box-shadow: {valid ? 'var(--shadow-lg)' : 'none'};
cursor: {valid ? 'pointer' : 'not-allowed'};
opacity: {valid ? 1 : 0.85};
"
>
Start cooking <Chevron size={16} color={valid ? '#fff' : 'var(--muted-2)'} dir="right" />
</button>
</div>
</div>
<style>
.screen {
min-height: 100%;
background: var(--bg);
display: flex;
flex-direction: column;
}
.back-bar {
position: sticky;
top: 0;
z-index: 5;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
padding: 60px 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.back-btn {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
}
.screen-title {
flex: 1;
font-size: 16px;
font-weight: 700;
text-align: center;
font-family: 'Lilita One', system-ui, sans-serif;
letter-spacing: -0.005em;
color: var(--ink);
}
.content {
padding: 24px 22px;
flex: 1;
}
.step-label {
font-size: 11px;
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--pink);
margin-bottom: 10px;
}
.headline {
font-size: 44px;
line-height: 0.95;
margin: 0 0 14px;
letter-spacing: -0.01em;
color: var(--ink);
}
.sub-text {
font-size: 14px;
color: var(--muted);
line-height: 1.45;
margin: 0 0 26px;
}
.input-ring {
border-radius: 20px;
padding: 2px;
background: var(--border-strong);
transition: background 0.2s;
}
.input-ring.focused {
background: var(--brand-gradient);
}
.input-inner {
background: var(--surface);
border-radius: 18px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.url-input {
flex: 1;
border: 0;
outline: 0;
background: transparent;
font-family: var(--font-mono);
font-size: 14px;
color: var(--ink);
min-width: 0;
}
.url-input::placeholder {
color: var(--muted-2);
}
.clear-btn {
width: 22px;
height: 22px;
border-radius: 99px;
background: var(--surface-3);
display: flex;
align-items: center;
justify-content: center;
}
.paste-btn {
width: 100%;
margin-top: 10px;
padding: 12px 16px;
border-radius: 14px;
background: var(--surface-2);
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.15s;
}
.hint-card {
margin-top: 26px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
}
.hint-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--purple);
letter-spacing: 1.2px;
text-transform: uppercase;
margin-bottom: 8px;
}
.hint-body {
font-size: 13px;
color: var(--ink-2);
line-height: 1.4;
}
.submit-wrap {
position: sticky;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px 34px;
background: linear-gradient(to top, var(--bg) 60%, transparent);
z-index: 40;
}
.submit-btn {
width: 100%;
padding: 16px 20px;
border-radius: 99px;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: opacity 0.15s;
}
</style>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
interface Props {
children: import('svelte').Snippet;
color?: string;
bg?: string;
mono?: boolean;
style?: string;
}
let { children, color, bg, mono = false, style = '' }: Props = $props();
</script>
<span
class="ic-chip"
style="
{bg ? `background: ${bg};` : ''}
{color ? `color: ${color};` : ''}
{mono ? `font-family: var(--font-mono);` : ''}
{style}
"
>
{@render children()}
</span>
<style>
.ic-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
padding: 4px 8px;
border-radius: 999px;
background: var(--surface-2);
color: var(--ink-2);
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import type { QueueItem } from '$lib/server/queue/types';
import CookingPot from './CookingPot.svelte';
import PhaseTrack from './PhaseTrack.svelte';
interface Props {
item: QueueItem;
onTap?: () => void;
}
let { item, onTap }: Props = $props();
const phaseMap: Record<string, string> = {
extraction: 'Prepping',
parsing: 'Simmering',
uploading: 'Plating'
};
const phaseHints: Record<string, string> = {
extraction: 'Pulling the post + ingredients off Instagram',
parsing: 'Reading the caption with an LLM',
uploading: 'Sending it to your Tandoor library'
};
function username(url: string) {
const m = url.match(/instagram\.com\/([^/?#]+)/);
return m ? '@' + m[1] : '@instagram';
}
function phase(item: QueueItem): 'prepping' | 'simmering' | 'plating' {
const map: Record<string, 'prepping' | 'simmering' | 'plating'> = {
extraction: 'prepping',
parsing: 'simmering',
uploading: 'plating'
};
return map[item.currentPhase ?? 'extraction'] ?? 'prepping';
}
function relTime(iso: string): string {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return Math.floor(diff / 3600) + 'h ago';
}
</script>
<button class="ic-btn-reset hero-card" onclick={onTap}>
<div class="gradient-strip"></div>
<div class="inner">
<div class="header-row">
<span class="cooking-chip">
<span class="ic-live chip-dot"></span>
Cooking now
</span>
<span class="time">{relTime(item.createdAt)}</span>
</div>
<div class="body-row">
<CookingPot size={90} phase={phase(item)} animate={true} />
<div class="text-area">
<div class="phase-label">{phaseMap[item.currentPhase ?? 'extraction']}</div>
<div class="phase-hint">{phaseHints[item.currentPhase ?? 'extraction']}</div>
<span class="source-chip">{username(item.url)}</span>
</div>
</div>
<PhaseTrack phase={phase(item)} />
</div>
</button>
<style>
.hero-card {
display: block;
width: calc(100% - 32px);
margin: 8px 16px 0;
border-radius: 28px;
overflow: hidden;
background: var(--surface);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
position: relative;
text-align: left;
}
.gradient-strip {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
background: var(--brand-gradient);
}
.inner {
padding: 20px 20px 18px;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.cooking-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 8px;
border-radius: 99px;
background: var(--brand-gradient);
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
font-family: var(--font-mono);
white-space: nowrap;
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 99px;
background: #fff;
display: inline-block;
}
.time {
font-size: 11px;
color: var(--muted);
font-family: var(--font-mono);
white-space: nowrap;
}
.body-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
margin-bottom: 14px;
}
.text-area {
flex: 1;
min-width: 0;
}
.phase-label {
font-family: 'Lilita One', system-ui, sans-serif;
font-size: 26px;
line-height: 1;
color: var(--ink);
margin-bottom: 6px;
letter-spacing: -0.01em;
}
.phase-hint {
font-size: 13px;
color: var(--muted);
line-height: 1.35;
margin-bottom: 8px;
}
.source-chip {
display: inline-block;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
color: var(--ink-2);
background: var(--surface-2);
padding: 3px 8px;
border-radius: 99px;
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
interface Props {
size?: number;
phase?: 'prepping' | 'simmering' | 'plating';
animate?: boolean;
}
let { size = 80, phase = 'simmering', animate = true }: Props = $props();
const steamCount = 3;
</script>
<div class="pot-wrap" style="width:{size}px; height:{size}px;">
<!-- Steam puffs -->
{#if animate && (phase === 'prepping' || phase === 'simmering')}
{#each { length: steamCount } as _, i}
<div
class="steam"
style="
left: {30 + i * 15}%;
animation-delay: {i * 0.45}s;
--drift: {(i - 1) * 5}px;
"
></div>
{/each}
{/if}
<!-- Pot SVG -->
<svg width={size} height={size} viewBox="0 0 80 80" fill="none">
<!-- Lid -->
<rect x="22" y="24" width="36" height="5" rx="2.5" fill="#3A2A40" />
<!-- Handle -->
<rect x="36" y="15" width="8" height="9" rx="3" fill="#3A2A40" />
<!-- Body -->
<path
d="M14 30 L16 58 Q16 66 24 66 L56 66 Q64 66 64 58 L66 30 Z"
fill={phase === 'prepping' ? '#FD7E14' : phase === 'simmering' ? '#E1306C' : '#833AB4'}
/>
<!-- Side handles -->
<rect x="8" y="34" width="10" height="6" rx="3" fill="#3A2A40" />
<rect x="62" y="34" width="10" height="6" rx="3" fill="#3A2A40" />
<!-- Bubbles (simmering/plating) -->
{#if animate && phase !== 'prepping'}
<circle cx="30" cy="52" r="3" fill="white" opacity="0.5" class="ic-bubble" />
<circle cx="42" cy="54" r="2.5" fill="white" opacity="0.45" class="ic-bubble" style="animation-delay:0.4s" />
<circle cx="52" cy="50" r="2" fill="white" opacity="0.4" class="ic-bubble" style="animation-delay:0.8s" />
{/if}
</svg>
</div>
<style>
.pot-wrap {
position: relative;
display: flex;
align-items: flex-end;
}
.steam {
position: absolute;
bottom: 60%;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.55);
animation: ic-steam 1.8s ease-in infinite;
animation-delay: var(--delay, 0s);
}
@keyframes ic-steam {
0% {
opacity: 0.7;
transform: translateY(0) translateX(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-22px) translateX(var(--drift, 0px)) scale(1.5);
}
}
</style>

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import CookingPot from './CookingPot.svelte';
import Clipboard from './ic/Clipboard.svelte';
import Close from './ic/Close.svelte';
import PhasePrepping from './ic/PhasePrepping.svelte';
import PhaseSimmering from './ic/PhaseSimmering.svelte';
import PhasePlating from './ic/PhasePlating.svelte';
interface Props {
onAdd?: () => void;
showHowTo?: boolean;
onDismissHowTo?: () => void;
}
let { onAdd, showHowTo = true, onDismissHowTo }: Props = $props();
</script>
<div class="empty">
<!-- Hero gradient card -->
<div class="hero-card">
<div class="pot-deco">
<CookingPot size={160} animate={true} />
</div>
<div class="tag">Empty kitchen</div>
<h1 class="ic-display hero-title">
Cook<br />anything<br />from a link.
</h1>
<p class="hero-sub">
Paste an Instagram recipe and we'll turn it into a real, savable recipe in Tandoor.
</p>
<button class="ic-btn-reset cta-btn" onclick={onAdd}>
<Clipboard size={16} color="var(--purple)" />
Paste your first link
</button>
</div>
<!-- How it works card -->
{#if showHowTo}
<div class="how-card">
<button class="ic-btn-reset dismiss-btn" onclick={onDismissHowTo} aria-label="Dismiss">
<Close size={14} color="var(--muted)" />
</button>
<div class="how-label">How it works</div>
{#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}
<div class="step" class:step-border={i > 0}>
<div class="step-icon">
<step.icon size={32} animate={true} />
</div>
<div class="step-body">
<div class="step-num">STEP {step.n}</div>
<div class="step-title">{step.t}</div>
<div class="step-desc">{step.d}</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.empty {
padding: 20px 20px 100px;
}
.hero-card {
margin-top: 16px;
border-radius: 32px;
padding: 32px 24px;
background: var(--brand-gradient);
color: #fff;
position: relative;
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.pot-deco {
position: absolute;
right: -30px;
top: -30px;
opacity: 0.22;
pointer-events: none;
}
.tag {
display: inline-block;
padding: 4px 10px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.25);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
font-family: var(--font-mono);
margin-bottom: 14px;
}
.hero-title {
font-size: 40px;
line-height: 0.95;
margin: 0 0 10px;
max-width: 240px;
}
.hero-sub {
font-size: 14px;
line-height: 1.4;
margin: 0 0 18px;
max-width: 240px;
opacity: 0.95;
}
.cta-btn {
background: #fff;
color: var(--purple);
padding: 12px 18px;
border-radius: 99px;
font-weight: 700;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.15);
}
.how-card {
margin-top: 24px;
border-radius: 24px;
padding: 20px;
background: var(--surface);
border: 1px solid var(--border);
position: relative;
}
.dismiss-btn {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border-radius: 99px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
}
.how-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
font-family: var(--font-mono);
color: var(--pink);
margin-bottom: 14px;
}
.step {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 12px 0;
}
.step-border {
border-top: 1px solid var(--border);
}
.step-icon {
width: 44px;
height: 44px;
border-radius: 14px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-num {
font-size: 10px;
font-family: var(--font-mono);
color: var(--muted);
font-weight: 700;
margin-bottom: 2px;
}
.step-title {
font-weight: 700;
font-size: 15px;
margin-bottom: 3px;
font-family: 'Lilita One', system-ui, sans-serif;
color: var(--ink);
}
.step-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
</style>

View File

@@ -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 @@
}
</script>
<!-- Main Install Prompt (for browsers with beforeinstallprompt support) -->
<!-- InstallSheet bottom sheet -->
{#if showPrompt && canInstall}
<div class="fixed bottom-0 left-0 right-0 z-50 transform transition-transform duration-300 ease-out animate-slide-up">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-2xl">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- App Icon -->
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-lg">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
</svg>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay ic-fade" onclick={handleDismiss}>
<div class="sheet ic-slide-up" onclick={(e) => e.stopPropagation()}>
<!-- Handle -->
<div class="handle-row"><div class="handle"></div></div>
<div class="inner">
<!-- App identity -->
<div class="app-row">
<img src="/icon-256.png" alt="InstaChef" width="58" height="58" class="app-icon" />
<div>
<Chip color="var(--pink)" bg="rgba(225,48,108,0.12)" mono>
<Spark size={10} color="var(--pink)" /> INSTALL
</Chip>
<div class="ic-display app-name">Put InstaChef on your home screen</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white">Install InstaRecipe</h3>
<p class="text-blue-100 text-sm">
Get faster access and offline support. Works like a native app!
</p>
<!-- Feature grid -->
<div class="feature-grid">
{#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}
<div class="feature-card">
<f.Icon size={16} color={f.color} />
<div class="feature-title">{f.t}</div>
<div class="feature-desc">{f.d}</div>
</div>
{/each}
</div>
<!-- Action Buttons -->
<div class="flex items-center space-x-2 ml-4">
<button
onclick={handleInstall}
disabled={installing}
class="bg-white text-blue-600 px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors disabled:opacity-50 flex items-center space-x-2 shadow-lg"
>
<!-- Install button -->
<button class="ic-btn-reset install-btn" onclick={handleInstall} disabled={installing}>
{#if installing}
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Installing...</span>
Installing…
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Install</span>
<Download size={18} color="#fff" /> Install InstaChef
{/if}
</button>
<button
onclick={handleDismiss}
class="text-blue-100 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
title="Dismiss"
>
<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="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- Features List -->
<div class="mt-3 flex flex-wrap gap-3 text-xs text-blue-100">
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Offline access</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Push notifications</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Faster loading</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Home screen access</span>
</div>
</div>
<button class="ic-btn-reset later-btn" onclick={handleDismiss}>Maybe later</button>
</div>
</div>
</div>
{/if}
<!-- Fallback Instructions (for browsers without beforeinstallprompt) -->
{#if showFallback && !canInstall && !pwaInstallManager.isStandalone()}
<div class="fixed bottom-4 right-4 max-w-sm bg-white border rounded-lg shadow-xl p-4 z-40 animate-fade-in">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<!-- Fallback hint (iOS Safari etc.) -->
{#if showFallback && !canInstall && browser && !pwaInstallManager.isStandalone()}
<div class="fallback ic-slide-up">
<div class="fallback-inner">
<div class="fallback-text">
<div class="fallback-title">Install InstaChef</div>
<div class="fallback-sub">{pwaInstallManager.getInstallInstructions()}</div>
</div>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-gray-900 mb-1">Install InstaRecipe</h4>
<p class="text-xs text-gray-600 mb-3">
{pwaInstallManager.getInstallInstructions()}
</p>
<!-- Browser-specific hints -->
{#if pwaInstallManager.getBrowserName() === 'safari'}
<div class="flex items-center space-x-1 text-xs text-blue-600 bg-blue-50 rounded px-2 py-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"></path>
</svg>
<span>Use the Share button</span>
</div>
{:else}
<div class="flex items-center space-x-1 text-xs text-green-600 bg-green-50 rounded px-2 py-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Look for install button</span>
</div>
{/if}
</div>
<button
onclick={handleDismiss}
class="text-gray-400 hover:text-gray-500 flex-shrink-0"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<button class="ic-btn-reset close-fallback" onclick={handleDismiss} aria-label="Dismiss"></button>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
.overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: flex-end;
background: rgba(0, 0, 0, 0.42);
}
to {
transform: translateY(0);
opacity: 1;
.sheet {
width: 100%;
border-radius: 32px 32px 0 0;
background: var(--bg);
box-shadow: 0 -20px 60px rgba(0, 0, 0, 0.3);
}
.handle-row {
display: flex;
justify-content: center;
margin-bottom: 16px;
padding-top: 14px;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
.handle {
width: 44px;
height: 5px;
border-radius: 99px;
background: var(--border-strong);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
.inner {
padding: 0 22px 30px;
}
.app-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
.app-icon {
border-radius: 16px;
box-shadow: 0 8px 22px rgba(225, 48, 108, 0.35);
flex-shrink: 0;
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
.app-name {
font-size: 22px;
line-height: 1.1;
color: var(--ink);
margin-top: 6px;
}
.feature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 18px;
}
.feature-card {
padding: 12px;
border-radius: 16px;
background: var(--surface);
border: 1px solid var(--border);
}
.feature-title {
font-size: 13px;
font-weight: 700;
margin: 6px 0 2px;
color: var(--ink);
}
.feature-desc {
font-size: 11px;
color: var(--muted);
line-height: 1.35;
}
.install-btn {
width: 100%;
background: var(--brand-gradient);
color: #fff;
padding: 16px;
border-radius: 99px;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: var(--shadow-lg);
margin-bottom: 8px;
}
.later-btn {
width: 100%;
padding: 12px;
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.fallback {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
padding: 12px 16px 34px;
background: var(--bg);
border-top: 1px solid var(--border);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.12);
}
.fallback-inner {
display: flex;
align-items: center;
gap: 12px;
}
.fallback-text {
flex: 1;
}
.fallback-title {
font-size: 14px;
font-weight: 700;
color: var(--ink);
margin-bottom: 2px;
}
.fallback-sub {
font-size: 12px;
color: var(--muted);
}
.close-fallback {
color: var(--muted);
font-size: 16px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 99px;
background: var(--surface-2);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,322 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pushNotificationManager, type NotificationState } from '$lib/client/PushNotificationManager';
import Chevron from './ic/Chevron.svelte';
import Bell from './ic/Bell.svelte';
import BellOff from './ic/BellOff.svelte';
import Chip from './Chip.svelte';
interface Props {
onBack?: () => void;
sseConnected?: boolean;
sseLastPing?: string;
}
let { onBack, sseConnected = false, sseLastPing = '' }: Props = $props();
let notifState = $state<NotificationState>({
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
});
let unsub: (() => void) | null = null;
onMount(() => {
unsub = pushNotificationManager.onStateChange((s) => {
notifState = s;
});
return () => unsub?.();
});
const enabled = $derived(notifState.subscribed);
async function handleToggle() {
await pushNotificationManager.toggleSubscription();
}
</script>
<div class="screen">
<!-- Back bar -->
<div class="back-bar">
<button class="ic-btn-reset back-btn" onclick={onBack} aria-label="Back">
<Chevron size={18} dir="left" color="var(--ink)" />
</button>
<div class="screen-title">Notifications</div>
<div style="width: 40px;"></div>
</div>
<div class="content">
<!-- Big toggle card -->
<div
class="toggle-card"
style="
background: {enabled ? 'var(--brand-gradient)' : 'var(--surface)'};
color: {enabled ? '#fff' : 'var(--ink)'};
border: {enabled ? 'none' : '1px solid var(--border)'};
box-shadow: {enabled ? 'var(--shadow-lg)' : 'var(--shadow-sm)'};
"
>
<div class="bell-deco" style="opacity: {enabled ? 0.18 : 0.06}">
{#if enabled}
<Bell size={140} color="#fff" filled={true} />
{:else}
<BellOff size={120} color="var(--muted)" />
{/if}
</div>
<div class="toggle-inner">
<Chip color={enabled ? '#fff' : 'var(--muted)'} bg={enabled ? 'rgba(255,255,255,0.22)' : 'var(--surface-2)'} mono>
{enabled ? '● LIVE' : 'OFF'}
</Chip>
<h2 class="ic-display toggle-title">
{enabled ? 'Push is on.' : "Get a ping when it's ready."}
</h2>
<p class="toggle-sub" style="color: {enabled ? 'rgba(255,255,255,0.95)' : 'var(--muted)'}">
{#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}
</p>
{#if notifState.error}
<div class="error-note">{notifState.error}</div>
{/if}
{#if notifState.permission === 'denied'}
<div class="denied-note">
Notifications are blocked. Enable them in your browser settings.
</div>
{:else}
<button
class="ic-btn-reset toggle-btn"
onclick={handleToggle}
disabled={!notifState.supported || notifState.loading}
style="
background: {enabled ? '#fff' : 'var(--ink)'};
color: {enabled ? 'var(--purple)' : 'var(--bg)'};
"
>
{#if notifState.loading}
Working…
{:else if enabled}
<BellOff size={14} color="var(--purple)" /> Turn off
{:else}
<Bell size={14} color="var(--bg)" /> Enable
{/if}
</button>
{/if}
</div>
</div>
<!-- What you'll hear about -->
<div class="info-card">
<div class="info-header">You'll hear about</div>
{#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}
<div class="info-row" class:info-row-border={i > 0}>
<div class="info-dot" style="background: {row.color}"></div>
<div class="info-text">
<div class="info-title">{row.title}</div>
<div class="info-desc">{row.desc}</div>
</div>
</div>
{/each}
</div>
<!-- SSE status -->
<div class="sse-card">
<div class="sse-label">Live queue</div>
<div class="sse-row">
<span class="sse-dot" style="background: {sseConnected ? 'var(--status-success)' : 'var(--status-error)'}"></span>
<span class="sse-status">{sseConnected ? 'SSE connected' : 'SSE disconnected'}</span>
<span class="sse-spacer"></span>
{#if sseLastPing}
<span class="sse-ping">{sseLastPing}</span>
{/if}
</div>
</div>
</div>
</div>
<style>
.screen {
min-height: 100%;
background: var(--bg);
padding-bottom: 60px;
}
.back-bar {
position: sticky;
top: 0;
z-index: 5;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
padding: 60px 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.back-btn {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
}
.screen-title {
flex: 1;
font-size: 16px;
font-weight: 700;
text-align: center;
font-family: 'Lilita One', system-ui, sans-serif;
color: var(--ink);
}
.content {
padding: 20px 18px;
}
.toggle-card {
border-radius: 26px;
padding: 24px;
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.bell-deco {
position: absolute;
right: -20px;
top: -20px;
pointer-events: none;
transition: opacity 0.3s;
}
.toggle-inner {
position: relative;
}
.toggle-title {
font-size: 30px;
line-height: 1;
margin: 14px 0 6px;
letter-spacing: -0.01em;
max-width: 240px;
}
.toggle-sub {
font-size: 13px;
line-height: 1.4;
margin: 0;
max-width: 260px;
}
.error-note {
margin-top: 10px;
font-size: 12px;
color: rgba(255, 80, 80, 0.9);
}
.denied-note {
margin-top: 14px;
font-size: 13px;
opacity: 0.8;
}
.toggle-btn {
margin-top: 18px;
padding: 12px 18px;
border-radius: 99px;
font-size: 14px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.info-card {
margin-top: 18px;
border-radius: 22px;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
}
.info-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
font-size: 11px;
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--muted);
}
.info-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
}
.info-row-border {
border-top: 1px solid var(--border);
}
.info-dot {
width: 10px;
height: 10px;
border-radius: 99px;
flex-shrink: 0;
}
.info-text {
flex: 1;
}
.info-title {
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.info-desc {
font-size: 12px;
color: var(--muted);
}
.sse-card {
margin-top: 18px;
border-radius: 22px;
padding: 18px;
background: var(--surface);
border: 1px solid var(--border);
}
.sse-label {
font-size: 11px;
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 10px;
}
.sse-row {
display: flex;
align-items: center;
gap: 8px;
}
.sse-dot {
width: 8px;
height: 8px;
border-radius: 99px;
flex-shrink: 0;
}
.sse-status {
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.sse-spacer {
flex: 1;
}
.sse-ping {
font-size: 11px;
color: var(--muted);
font-family: var(--font-mono);
}
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
type Phase = 'prepping' | 'simmering' | 'plating';
interface Props {
phase: Phase;
}
let { phase }: Props = $props();
const phases: { id: Phase; label: string }[] = [
{ id: 'prepping', label: 'Prepping' },
{ id: 'simmering', label: 'Simmering' },
{ id: 'plating', label: 'Plating' }
];
const colors: Record<Phase, string> = {
prepping: '#FD7E14',
simmering: '#E1306C',
plating: '#833AB4'
};
const phaseIndex: Record<Phase, number> = { prepping: 0, simmering: 1, plating: 2 };
const current = $derived(phaseIndex[phase]);
</script>
<div class="phase-track">
{#each phases as p, i}
{@const state = i < current ? 'done' : i === current ? 'active' : 'idle'}
<div class="segment {state}" style={state !== 'idle' ? `--clr: ${colors[p.id]}` : ''}>
<div class="bar"></div>
<span class="label">{p.label}</span>
</div>
{#if i < phases.length - 1}
<div class="connector {i < current ? 'done' : ''}"></div>
{/if}
{/each}
</div>
<style>
.phase-track {
display: flex;
align-items: flex-start;
gap: 0;
width: 100%;
}
.segment {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
flex: 1;
}
.bar {
height: 5px;
width: 100%;
border-radius: 3px;
background: var(--surface-2);
transition: background 0.35s;
}
.segment.active .bar,
.segment.done .bar {
background: var(--clr);
}
.label {
font-size: 10px;
font-weight: 600;
color: var(--ink-3);
letter-spacing: 0.2px;
transition: color 0.35s;
}
.segment.active .label {
color: var(--clr);
}
.segment.done .label {
color: var(--ink-2);
}
.connector {
width: 4px;
height: 5px;
flex-shrink: 0;
background: var(--surface-2);
margin-top: 0;
transition: background 0.35s;
}
.connector.done {
background: var(--ink-3);
}
</style>

View File

@@ -0,0 +1,435 @@
<script lang="ts">
import type { QueueItem } from '$lib/server/queue/types';
import Chip from './Chip.svelte';
import Close from './ic/Close.svelte';
import Check from './ic/Check.svelte';
import Retry from './ic/Retry.svelte';
import Link from './ic/Link.svelte';
import External from './ic/External.svelte';
import PhasePrepping from './ic/PhasePrepping.svelte';
import PhaseSimmering from './ic/PhaseSimmering.svelte';
import PhasePlating from './ic/PhasePlating.svelte';
interface Props {
item: QueueItem | null;
onClose?: () => void;
onRetry?: (id: string) => void;
}
let { item, onClose, onRetry }: Props = $props();
const PALETTE = ['#E1306C', '#FD7E14', '#FCAF45', '#833AB4', '#C13584'];
function strHash(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function swatch(id: string): [string, string] {
const h = strHash(id);
return [PALETTE[h % PALETTE.length], PALETTE[(h + 2) % PALETTE.length]];
}
const isSuccess = $derived(item?.status === 'success');
const isError = $derived(item?.status === 'error' || item?.status === 'unhealthy');
const isCooking = $derived(item?.status === 'in_progress');
const isPending = $derived(item?.status === 'pending');
const recipe = $derived(item?.recipe ?? item?.results?.recipe);
const sw = $derived(item ? swatch(item.id) : (['#E1306C', '#FD7E14'] as [string, string]));
function statusLabel(item: QueueItem): string {
if (item.status === 'success') return 'Saved';
if (item.status === 'error' || item.status === 'unhealthy') return 'Failed';
if (item.status === 'in_progress') {
const m: Record<string, string> = { extraction: 'Prepping', parsing: 'Simmering', uploading: 'Plating' };
return m[item.currentPhase ?? 'extraction'] ?? 'Cooking';
}
return 'Pending';
}
function username(url: string) {
const m = url.match(/instagram\.com\/([^/?#]+)/);
return m ? '@' + m[1] : '@instagram';
}
const phases = [
{ name: 'extraction', label: 'Prepping', desc: 'Grabbing post + media', Icon: PhasePrepping },
{ name: 'parsing', label: 'Simmering', desc: 'LLM reads the caption', Icon: PhaseSimmering },
{ name: 'uploading', label: 'Plating', desc: 'Saving to Tandoor', Icon: PhasePlating }
];
</script>
{#if item}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay ic-fade" onclick={onClose} role="dialog" aria-modal="true">
<div class="sheet ic-slide-up" onclick={(e) => e.stopPropagation()}>
<!-- Handle -->
<div class="handle-row">
<div class="handle"></div>
</div>
<!-- Cover -->
<div class="cover" style="background: linear-gradient(135deg, {sw[0]} 0%, {sw[1]} 100%)">
<div class="cover-ring outer-ring"></div>
<div class="cover-ring inner-ring"></div>
<div class="cover-top">
<Chip color="#fff" bg="rgba(255,255,255,0.22)" mono>
{statusLabel(item).toUpperCase()}
</Chip>
<button class="ic-btn-reset close-btn" onclick={onClose} aria-label="Close">
<Close size={16} color="#fff" />
</button>
</div>
<h2 class="ic-display cover-title">
{recipe?.name || (isPending ? 'Waiting in line' : isCooking ? statusLabel(item) + '…' : 'Untitled')}
</h2>
{#if recipe?.servings}
<div class="cover-meta">Serves {recipe.servings} · from {username(item.url)}</div>
{/if}
</div>
<!-- Body -->
<div class="body">
<!-- Keywords -->
{#if recipe?.keywords?.length > 0}
<div class="tags-row">
{#each recipe.keywords as kw}
<Chip bg="var(--surface-2)" color="var(--ink-2)" mono>#{kw}</Chip>
{/each}
</div>
{/if}
<!-- Phase progress (cooking) -->
{#if isCooking}
<div class="phase-card">
<div class="phase-card-label">3-phase progress</div>
{#each phases as p, i}
{@const ph = item.phases.find((x) => x.name === p.name) || { status: 'pending' }}
{@const done = ph.status === 'completed'}
{@const active = ph.status === 'in_progress'}
<div class="phase-row" style="opacity: {done || active ? 1 : 0.4}">
<div class="phase-icon-wrap" class:phase-active={active}>
<p.Icon size={32} animate={active} />
{#if done}
<div class="phase-done-badge">
<Check size={10} strokeWidth={3.5} color="#fff" />
</div>
{/if}
</div>
<div class="phase-text">
<div class="phase-title">{p.label}</div>
<div class="phase-desc">{p.desc}</div>
</div>
{#if active}
<span class="running-tag ic-pulse">RUNNING</span>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Error detail -->
{#if isError && item.error}
<div class="error-card">
<div class="error-title">
<span class="error-badge">!</span>
Failed during {item.error.phase}
</div>
<div class="error-msg">{item.error.message}</div>
<button class="ic-btn-reset retry-btn" onclick={() => onRetry?.(item!.id)}>
<Retry size={14} color="var(--status-error)" /> Try again
</button>
</div>
{/if}
<!-- Source URL -->
<div class="source-card">
<div class="source-icon">
<Link size={16} color="#fff" />
</div>
<div class="source-info">
<div class="source-label">SOURCE</div>
<div class="source-url">{item.url}</div>
</div>
</div>
<!-- Open in Tandoor -->
{#if isSuccess && (item.results?.tandoorUrl ?? item.tandoorRecipeId)}
{@const tandoorUrl =
item.results?.tandoorUrl ??
`/api/v1/recipe/${item.results?.tandoorRecipeId ?? item.tandoorRecipeId}/`}
<a href={tandoorUrl} target="_blank" rel="noopener" class="ic-btn-reset tandoor-btn">
Open in Tandoor <External size={16} color="#fff" />
</a>
{/if}
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: flex-end;
background: rgba(0, 0, 0, 0.42);
}
.sheet {
background: var(--bg);
width: 100%;
border-radius: 32px 32px 0 0;
max-height: 94vh;
overflow-y: auto;
box-shadow: 0 -20px 60px rgba(0, 0, 0, 0.3);
overscroll-behavior: contain;
}
.handle-row {
display: flex;
justify-content: center;
padding: 10px 0 4px;
}
.handle {
width: 44px;
height: 5px;
border-radius: 99px;
background: var(--border-strong);
}
.cover {
margin: 8px 16px 0;
border-radius: 24px;
padding: 24px 22px;
position: relative;
overflow: hidden;
color: #fff;
min-height: 180px;
}
.cover-ring {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.outer-ring {
right: -60px;
bottom: -60px;
width: 220px;
height: 220px;
border: 2px solid rgba(255, 255, 255, 0.25);
}
.inner-ring {
right: -30px;
bottom: -30px;
width: 160px;
height: 160px;
background: rgba(255, 255, 255, 0.12);
}
.cover-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 80px;
position: relative;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.22);
display: flex;
align-items: center;
justify-content: center;
}
.cover-title {
font-size: 32px;
line-height: 1;
margin: 0;
letter-spacing: -0.01em;
position: relative;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.cover-meta {
margin-top: 14px;
font-size: 13px;
font-family: var(--font-mono);
opacity: 0.92;
position: relative;
}
.body {
padding: 22px 22px 44px;
}
.tags-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 20px;
}
.phase-card {
border-radius: 20px;
padding: 18px;
margin-bottom: 16px;
background: var(--surface);
border: 1px solid var(--border);
}
.phase-card-label {
font-size: 11px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--muted);
letter-spacing: 1.2px;
text-transform: uppercase;
margin-bottom: 14px;
}
.phase-row {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 0;
transition: opacity 0.3s;
}
.phase-icon-wrap {
width: 44px;
height: 44px;
border-radius: 14px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
}
.phase-active {
background: var(--bg-tint);
}
.phase-done-badge {
position: absolute;
bottom: -2px;
right: -2px;
width: 18px;
height: 18px;
border-radius: 99px;
background: var(--status-success);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--surface);
}
.phase-title {
font-weight: 700;
font-size: 14px;
font-family: 'Lilita One', system-ui, sans-serif;
color: var(--ink);
}
.phase-desc {
font-size: 12px;
color: var(--muted);
}
.running-tag {
font-size: 10px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--pink);
letter-spacing: 0.6px;
}
.error-card {
border-radius: 20px;
padding: 18px;
background: #ffe9e9;
border: 1px solid #f8c2c2;
color: #7e1717;
margin-bottom: 16px;
}
.error-title {
font-weight: 700;
font-size: 14px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.error-badge {
width: 20px;
height: 20px;
border-radius: 99px;
background: var(--status-error);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 13px;
}
.error-msg {
font-size: 13px;
line-height: 1.4;
}
.retry-btn {
margin-top: 14px;
padding: 10px 16px;
border-radius: 99px;
background: #fff;
color: var(--status-error);
font-weight: 700;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #f8c2c2;
}
.source-card {
border-radius: 20px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.source-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: var(--brand-gradient);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.source-info {
flex: 1;
min-width: 0;
}
.source-label {
font-size: 11px;
color: var(--muted);
font-family: var(--font-mono);
font-weight: 600;
margin-bottom: 2px;
}
.source-url {
font-size: 13px;
font-family: var(--font-mono);
color: var(--ink-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tandoor-btn {
width: 100%;
padding: 16px;
background: var(--brand-gradient);
color: #fff;
border-radius: 99px;
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: var(--shadow-lg);
text-decoration: none;
}
</style>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
// Deterministic 2-color gradient thumbnail from a string id.
const PALETTE = ['#E1306C', '#FD7E14', '#FCAF45', '#833AB4', '#C13584'];
function strHash(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
interface Props {
id: string;
size?: number;
emoji?: string;
}
let { id, size = 52, emoji }: Props = $props();
const swatch = $derived.by(() => {
const h = strHash(id);
const a = PALETTE[h % PALETTE.length];
const b = PALETTE[(h + 2) % PALETTE.length];
return [a, b] as [string, string];
});
</script>
<div class="recipe-thumb" style="width:{size}px; height:{size}px;">
<div
class="gradient"
style="background: linear-gradient(135deg, {swatch[0]}, {swatch[1]});"
>
<div class="ring outer"></div>
<div class="ring inner"></div>
{#if emoji}
<span class="emoji" style="font-size:{size * 0.38}px">{emoji}</span>
{:else}
<svg class="fork" width={size * 0.42} height={size * 0.42} viewBox="0 0 24 24" fill="none">
<path
d="M9 3v6a3 3 0 01-3 3h0a3 3 0 01-3-3V3M6 12v9M15 3v3a3 3 0 003 3h0v9"
stroke="rgba(255,255,255,0.8)"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
{/if}
</div>
</div>
<style>
.recipe-thumb {
flex-shrink: 0;
border-radius: 14px;
overflow: hidden;
}
.gradient {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.ring {
position: absolute;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.25);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.outer {
width: 90%;
height: 90%;
}
.inner {
width: 68%;
height: 68%;
}
.emoji {
position: relative;
z-index: 1;
}
.fork {
position: relative;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
interface Props {
children: import('svelte').Snippet;
emoji?: string;
}
let { children, emoji }: Props = $props();
</script>
<div class="section-head">
{#if emoji}<span class="emoji">{emoji}</span>{/if}
<span class="label">{@render children()}</span>
</div>
<style>
.section-head {
display: flex;
align-items: center;
gap: 5px;
padding: 0 16px 6px;
}
.emoji {
font-size: 14px;
}
.label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--ink-2);
}
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import type { QueueItem } from '$lib/server/queue/types';
import RecipeThumb from './RecipeThumb.svelte';
import Retry from './ic/Retry.svelte';
import Chevron from './ic/Chevron.svelte';
import Check from './ic/Check.svelte';
interface Props {
item: QueueItem;
onTap?: () => void;
onRetry?: (id: string) => void;
queuePosition?: number;
}
let { item, onTap, onRetry, queuePosition }: Props = $props();
const isError = $derived(item.status === 'error' || item.status === 'unhealthy');
const isSuccess = $derived(item.status === 'success');
const isPending = $derived(item.status === 'pending');
function username(url: string) {
const m = url.match(/instagram\.com\/([^/?#]+)/);
return m ? '@' + m[1] : '@instagram';
}
function relTime(iso: string): string {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
const recipe = $derived(item.recipe ?? item.results?.recipe);
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="row" role="button" tabindex="0" onclick={onTap} onkeydown={(e) => e.key === 'Enter' && onTap?.()}>
<!-- Thumbnail -->
<div class="thumb-wrap">
{#if isPending}
<div class="pending-thumb">
<span class="queue-pos">#{queuePosition ?? 1}</span>
</div>
{:else}
<RecipeThumb id={item.id} size={56} />
{/if}
{#if isError}
<div class="badge badge-error">!</div>
{:else if isSuccess}
<div class="badge badge-success">
<Check size={12} strokeWidth={3.4} color="#fff" />
</div>
{/if}
</div>
<!-- Body -->
<div class="body">
<div class="title">
{recipe?.name || (isPending ? 'Waiting in line…' : 'Untitled recipe')}
</div>
<div class="meta">
<span class="uname">{username(item.url)}</span>
<span class="sep">·</span>
<span>{relTime(item.createdAt)}</span>
</div>
{#if isError && item.error}
<div class="error-line">{item.error.message?.slice(0, 60)}</div>
{/if}
</div>
<!-- Tail -->
<div class="tail">
{#if isError}
<button
class="ic-btn-reset retry-btn"
onclick={(e) => { e.stopPropagation(); onRetry?.(item.id); }}
aria-label="Retry"
>
<Retry size={18} color="var(--pink)" />
</button>
{:else}
<Chevron size={18} color="var(--muted-2)" dir="right" />
{/if}
</div>
</div>
<style>
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
cursor: pointer;
width: 100%;
background: transparent;
-webkit-tap-highlight-color: transparent;
}
.row:active {
background: var(--surface-2);
}
.thumb-wrap {
position: relative;
flex-shrink: 0;
}
.pending-thumb {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border-strong);
}
.queue-pos {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
color: var(--muted);
}
.badge {
position: absolute;
bottom: -4px;
right: -4px;
width: 20px;
height: 20px;
border-radius: 99px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--bg);
font-size: 13px;
font-weight: 800;
}
.badge-error {
background: var(--status-error);
color: #fff;
}
.badge-success {
background: var(--status-success);
}
.body {
flex: 1;
min-width: 0;
}
.title {
font-size: 15px;
font-weight: 600;
color: var(--ink);
margin-bottom: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.uname {
font-family: var(--font-mono);
color: var(--ink-2);
font-weight: 600;
}
.sep {
opacity: 0.5;
}
.error-line {
font-size: 12px;
color: var(--status-error);
margin-top: 4px;
font-weight: 500;
}
.tail {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.retry-btn {
width: 36px;
height: 36px;
border-radius: 12px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Bell from './ic/Bell.svelte';
interface Props {
count?: number;
notifCount?: number;
onNotifications?: () => void;
}
let { count = 0, notifCount = 0, onNotifications }: Props = $props();
</script>
<div class="topbar">
<div class="brand">
<img src="/icon-256.png" alt="InstaChef" width="38" height="38" class="logo" />
<div>
<div class="ic-display app-name">InstaChef</div>
<div class="sub">
<span class="dot ic-live"></span>
LIVE · {count} RECIPES
</div>
</div>
</div>
<div class="actions">
<button class="ic-btn-reset bell-btn" onclick={onNotifications} aria-label="Notifications">
<Bell size={20} />
{#if notifCount > 0}
<span class="notif-dot"></span>
{/if}
</button>
</div>
</div>
<style>
.topbar {
position: sticky;
top: 0;
z-index: 5;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
padding: 60px 18px 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.logo {
border-radius: 11px;
box-shadow: 0 2px 8px rgba(225, 48, 108, 0.25);
flex-shrink: 0;
}
.app-name {
font-size: 22px;
color: var(--ink);
line-height: 1;
white-space: nowrap;
}
.sub {
font-size: 10px;
color: var(--muted);
font-family: var(--font-mono);
letter-spacing: 0.6px;
margin-top: 3px;
white-space: nowrap;
display: flex;
align-items: center;
gap: 5px;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 99px;
background: var(--status-success);
}
.actions {
display: flex;
gap: 4px;
}
.bell-btn {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink);
position: relative;
}
.notif-dot {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 99px;
background: var(--pink);
border: 2px solid var(--bg);
}
</style>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 20, color = 'currentColor', filled = false }: { size?: number; color?: string; filled?: boolean } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill={filled ? color : 'none'} stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0112 0c0 6 2 7 2 9H4c0-2 2-3 2-9z" />
<path d="M10 20a2 2 0 004 0" />
</svg>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 019.4-4.9M6 8c0 6-2 7-2 9h12" />
<path d="M18 14V8a6 6 0 00-.3-1.9" />
<path d="M10 20a2 2 0 004 0" />
<path d="M3 3l18 18" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 18, color = 'currentColor', strokeWidth = 2.4 }: { size?: number; color?: string; strokeWidth?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width={strokeWidth} stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12l5 5L20 7" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 18, color = 'currentColor', dir = 'right' }: { size?: number; color?: string; dir?: 'right' | 'left' | 'up' | 'down' } = $props();
const rot = { right: 0, left: 180, up: -90, down: 90 }[dir];
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate({rot}deg)">
<path d="M9 6l6 6-6 6" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="4" width="12" height="18" rx="2" />
<path d="M9 4V3a1 1 0 011-1h4a1 1 0 011 1v1" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v12M8 12l4 4 4-4" />
<path d="M20 18v2H4v-2" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 16, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 4h6v6M10 14L20 4M19 13v6a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1h6" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 5h16M7 12h10M10 19h4" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 14a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1" />
<path d="M14 10a5 5 0 00-7 0l-3 3a5 5 0 007 7l1-1" />
</svg>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
let { size = 40, animate = false, color = '#833AB4' }: { size?: number; animate?: boolean; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
<ellipse cx="24" cy="34" rx="18" ry="5" fill={color} opacity="0.2" />
<ellipse cx="24" cy="32" rx="18" ry="5" fill={color} />
<ellipse cx="24" cy="31" rx="14" ry="3" fill="white" opacity="0.5" />
<circle cx="20" cy="30" r="3.5" fill="#FCAF45" />
<circle cx="26" cy="29" r="3" fill="#FD7E14" />
<circle cx="28" cy="32" r="2.5" fill="#E1306C" />
<path d="M18 18 Q14 12 18 9 Q22 5 24 9 Q26 5 30 9 Q34 12 30 18 Z" fill="white" stroke={color} stroke-width="1.4" />
<rect x="18" y="18" width="12" height="4" rx="1" fill="white" stroke={color} stroke-width="1.4" />
</svg>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
let { size = 40, animate = false, color = '#FD7E14' }: { size?: number; animate?: boolean; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
<rect x="6" y="28" width="36" height="10" rx="3" fill={color} opacity="0.18" />
<rect x="6" y="28" width="36" height="3" rx="1.5" fill={color} opacity="0.32" />
<circle cx="14" cy="26" r="2.4" fill={color} />
<circle cx="20" cy="26" r="2.4" fill={color} />
<circle cx="26" cy="26" r="2.4" fill={color} opacity="0.6" />
<g class={animate ? 'ic-chop' : ''} style="transform-origin: 38px 26px">
<rect x="32" y="12" width="3.5" height="14" rx="1.2" fill="#3A2A40" />
<path d="M35.5 12 L40 8 L40 24 L35.5 26 Z" fill="#C8CDD4" stroke="#3A2A40" stroke-width="0.8" />
</g>
</svg>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
let { size = 40, animate = false, color = '#E1306C' }: { size?: number; animate?: boolean; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
{#if animate}
<circle cx="20" cy="14" r="2.5" fill={color} opacity="0.5" class="ic-steam" style="--delay: 0s; --drift: 4px" />
<circle cx="26" cy="12" r="2" fill={color} opacity="0.5" class="ic-steam" style="--delay: 0.6s; --drift: -4px" />
<circle cx="23" cy="16" r="1.8" fill={color} opacity="0.4" class="ic-steam" style="--delay: 1.2s; --drift: 6px" />
{/if}
<ellipse cx="24" cy="20" rx="16" ry="3" fill="#3A2A40" />
<rect x="22" y="14" width="4" height="5" rx="1.5" fill="#3A2A40" />
<path d="M8 21 L10 38 Q10 42 14 42 L34 42 Q38 42 38 38 L40 21 Z" fill={color} />
{#if animate}
<circle cx="18" cy="32" r="1.5" fill="white" opacity="0.7" class="ic-bubble" style="animation-delay: 0.3s" />
<circle cx="26" cy="34" r="1.2" fill="white" opacity="0.6" class="ic-bubble" style="animation-delay: 0.7s" />
<circle cx="30" cy="32" r="1" fill="white" opacity="0.6" class="ic-bubble" style="animation-delay: 1.1s" />
{/if}
<rect x="4" y="24" width="6" height="3" rx="1.5" fill="#3A2A40" />
<rect x="38" y="24" width="6" height="3" rx="1.5" fill="#3A2A40" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 20, color = 'currentColor', strokeWidth = 2.4 }: { size?: number; color?: string; strokeWidth?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width={strokeWidth} stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1015-7l3 1M21 4v5h-5" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.2" stroke-linecap="round">
<circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 16V4M8 8l4-4 4 4" />
<path d="M20 16v3a2 2 0 01-2 2H6a2 2 0 01-2-2v-3" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<path d="M12 2l1.6 6.4L20 10l-6.4 1.6L12 18l-1.6-6.4L4 10l6.4-1.6L12 2z" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />
</svg>

View File

@@ -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; }

View File

@@ -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<string[]>([]);
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();
}
</script>
<svelte:head>
<title>Share to InstaRecipe</title>
<meta name="description" content="Share Instagram recipes for extraction" />
<title>Add Recipe — InstaChef</title>
<meta name="description" content="Share Instagram recipes to InstaChef" />
</svelte:head>
<div class="mx-auto p-6 max-w-4xl">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 text-center">Share to InstaRecipe</h1>
<p class="text-gray-600 text-center">
{#if targetUrl}
Processing your shared recipe...
{:else}
Paste an Instagram recipe URL to extract it
{/if}
</p>
</div>
{#if !targetUrl}
<UrlInputSection onProcess={process} />
{:else}
<!-- Status indicator for shared URLs -->
<div class="max-w-2xl mx-auto mb-8">
<div class="bg-white p-6 rounded-lg shadow-md border">
<h3 class="font-semibold mb-2">Processing URL:</h3>
<p class="text-sm text-gray-600 mb-4 break-all">{targetUrl}</p>
{#if status === 'enqueuing' || status === 'success'}
<!-- Transitional feedback while enqueuing -->
<div class="enqueue-screen">
<div class="enqueue-card">
{#if status === 'enqueuing'}
<div class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span class="text-blue-600">Enqueuing for processing...</span>
</div>
{:else if status === 'error'}
<div class="flex items-center space-x-2 mb-4">
<span class="text-red-600">❌ Error occurred</span>
</div>
<button
onclick={retry}
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Retry
</button>
<div class="spinner"></div>
<div class="enqueue-title">Adding to queue…</div>
{:else}
<div class="text-green-600">✅ Ready to process</div>
<div class="enqueue-check"></div>
<div class="enqueue-title">Added! Redirecting…</div>
{/if}
<div class="enqueue-url">{targetUrl}</div>
</div>
</div>
{/if}
{:else}
<AddUrlScreen
initialUrl={targetUrl}
onBack={() => goto('/')}
onSubmit={process}
/>
{/if}
<!-- Log viewer for feedback -->
{#if logs.length > 0}
<div class="max-w-2xl mx-auto mt-8">
<div class="bg-gray-50 p-4 rounded-lg border">
<h3 class="font-semibold mb-2">Process Log:</h3>
<div class="space-y-1 text-sm">
{#each logs as log}
<div class="text-gray-700">{log}</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<style>
.enqueue-screen {
min-height: 100vh;
min-height: 100dvh;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.enqueue-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 28px;
padding: 40px 32px;
text-align: center;
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.spinner {
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid var(--border);
border-top-color: var(--pink);
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.enqueue-check {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--status-success);
color: #fff;
font-size: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.enqueue-title {
font-family: 'Lilita One', system-ui, sans-serif;
font-size: 22px;
color: var(--ink);
}
.enqueue-url {
font-size: 12px;
font-family: var(--font-mono);
color: var(--muted);
word-break: break-all;
max-width: 300px;
}
</style>

BIN
static/icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -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",