All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m1s
- RecipeSheet: add onDelete prop and 'Remove from queue' button at bottom of sheet - +page.svelte: wire onDelete -> removeItem in RecipeSheet - POST /api/queue: return full QueueItem (with createdAt, phases) instead of stripped subset - TimelineRow: defensive relTime() handles undefined/NaN, uses createdAt ?? enqueuedAt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
461 lines
14 KiB
Svelte
461 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { browser } from '$app/environment';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
|
|
import { replaceState } from '$app/navigation';
|
|
import { pushNotificationManager } 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 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);
|
|
|
|
// 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)
|
|
const highlightId = $derived($page.url.searchParams.get('highlight'));
|
|
|
|
// ── Derived ────────────────────────────────────────────────
|
|
const filteredItems = $derived.by(() => {
|
|
if (filter === 'all') return items;
|
|
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 () => {
|
|
await loadQueueItems();
|
|
if (browser) {
|
|
startSSEConnection();
|
|
setupAutoSubscribe();
|
|
unsubscribeNotifications = pushNotificationManager.onStateChange(() => {});
|
|
|
|
// Open RecipeSheet for highlighted item
|
|
if (highlightId) {
|
|
const found = items.find((i) => i.id === highlightId);
|
|
if (found) { selectedItem = found; clearHighlight(); }
|
|
}
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
eventSource?.close();
|
|
connectionStatus = 'disconnected';
|
|
unsubscribeNotifications?.();
|
|
});
|
|
|
|
// ── Data fetching ──────────────────────────────────────────
|
|
async function loadQueueItems() {
|
|
try {
|
|
loading = true;
|
|
loadError = null;
|
|
const response = await fetch('/api/queue');
|
|
if (!response.ok) throw new Error('Failed to load queue items');
|
|
const data = await response.json();
|
|
items = data.items || [];
|
|
} catch (e) {
|
|
loadError = e instanceof Error ? e.message : 'Unknown error';
|
|
console.error('Failed to load queue items:', e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
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 { item: 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';
|
|
try {
|
|
eventSource = new EventSource('/api/queue/stream');
|
|
eventSource.addEventListener('open', () => { connectionStatus = 'connected'; });
|
|
eventSource.addEventListener('connection', () => { connectionStatus = 'connected'; });
|
|
eventSource.addEventListener('queue-update', (event) => {
|
|
updateQueueItem(JSON.parse(event.data) as QueueStatusUpdate);
|
|
});
|
|
eventSource.addEventListener('error', () => {
|
|
connectionStatus = 'disconnected';
|
|
setTimeout(() => {
|
|
if (eventSource?.readyState === 2) startSSEConnection();
|
|
}, 5000);
|
|
});
|
|
eventSource.addEventListener('ping', (event) => {
|
|
lastPing = JSON.parse(event.data).timestamp;
|
|
});
|
|
} catch (e) {
|
|
console.error('[SSE] Failed to start:', e);
|
|
connectionStatus = 'disconnected';
|
|
}
|
|
}
|
|
|
|
function setupAutoSubscribe() {
|
|
if (hasAttemptedAutoSubscribe) return;
|
|
const attempt = async () => {
|
|
if (hasAttemptedAutoSubscribe) return;
|
|
hasAttemptedAutoSubscribe = true;
|
|
const state = pushNotificationManager.getState();
|
|
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
|
|
await pushNotificationManager.subscribe();
|
|
}
|
|
};
|
|
document.addEventListener('click', attempt, { once: true });
|
|
document.addEventListener('touchstart', attempt, { once: true });
|
|
}
|
|
|
|
function updateQueueItem(update: QueueStatusUpdate) {
|
|
const idx = items.findIndex((i) => i.id === update.itemId);
|
|
if (idx >= 0) {
|
|
items[idx] = {
|
|
...items[idx],
|
|
status: update.status,
|
|
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 {
|
|
fetchQueueItem(update.itemId);
|
|
}
|
|
items = [...items];
|
|
}
|
|
|
|
async function fetchQueueItem(id: string) {
|
|
try {
|
|
const response = await fetch(`/api/queue/${id}`);
|
|
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' });
|
|
if (!response.ok) {
|
|
const err = await response.json();
|
|
throw new Error(err.message || 'Failed to retry');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to retry item:', e);
|
|
}
|
|
}
|
|
|
|
async function removeItem(id: string) {
|
|
try {
|
|
await fetch(`/api/queue/${id}`, { method: 'DELETE' });
|
|
} catch (e) {
|
|
console.error('Failed to remove item:', e);
|
|
} finally {
|
|
items = items.filter((i) => i.id !== id);
|
|
if (selectedItem?.id === id) selectedItem = null;
|
|
}
|
|
}
|
|
|
|
function clearHighlight() {
|
|
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>InstaChef</title>
|
|
<meta name="description" content="Cook anything from an Instagram link." />
|
|
</svelte:head>
|
|
|
|
<div class="app-root">
|
|
<!-- ── Home screen ────────────────────────────────────────── -->
|
|
{#if screen === 'home'}
|
|
<div class="ic-scroll home-scroll">
|
|
<TopBar
|
|
count={items.length}
|
|
notifCount={0}
|
|
onNotifications={() => (screen = 'notifications')}
|
|
/>
|
|
|
|
{#if loading}
|
|
<div class="loading-wrap">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
{:else if loadError}
|
|
<div class="err-banner">Failed to load queue: {loadError}</div>
|
|
{:else if items.length === 0}
|
|
<EmptyState
|
|
onAdd={() => (screen = 'addurl')}
|
|
{showHowTo}
|
|
onDismissHowTo={() => (showHowTo = false)}
|
|
/>
|
|
{:else}
|
|
<!-- Cooking hero -->
|
|
{#if cooking && filter !== 'success' && filter !== 'error'}
|
|
<CookingHero item={cooking} onTap={() => (selectedItem = cooking)} />
|
|
{/if}
|
|
|
|
<!-- Filter chips -->
|
|
<div class="ic-scroll filter-row">
|
|
{#each [
|
|
{ id: 'all', label: 'All', count: items.length },
|
|
{ id: 'in_progress', label: 'Cooking', count: items.filter((i) => i.status === 'in_progress').length },
|
|
{ id: 'success', label: 'Saved', count: items.filter((i) => i.status === 'success').length },
|
|
{ id: 'error', label: 'Failed', count: items.filter((i) => i.status === 'error' || i.status === 'unhealthy').length }
|
|
] as f}
|
|
<button
|
|
class="ic-btn-reset filter-chip"
|
|
class:active={filter === f.id}
|
|
onclick={() => (filter = f.id as typeof filter)}
|
|
>
|
|
{f.label}
|
|
<span class="chip-count">{f.count}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Timeline groups -->
|
|
<div class="timeline">
|
|
{#each (['In line', 'Today', 'Yesterday', 'Earlier'] as const) as g}
|
|
{#if groups[g]?.length}
|
|
<SectionHead>{g}</SectionHead>
|
|
{#each groups[g] as it (it.id)}
|
|
<TimelineRow
|
|
item={it}
|
|
queuePosition={queuePos(it)}
|
|
onTap={() => (selectedItem = it)}
|
|
onRetry={retryItem}
|
|
/>
|
|
{/each}
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Sticky add button -->
|
|
<div class="add-fab-wrap">
|
|
<button class="ic-btn-reset add-fab" onclick={() => (screen = 'addurl')}>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round">
|
|
<path d="M9 4v9a4 4 0 004 4h8M9 8h4M5 20h8" />
|
|
</svg>
|
|
Paste Instagram link
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Add URL screen ────────────────────────────────────── -->
|
|
{:else if screen === 'addurl'}
|
|
<div class="ic-scroll screen-scroll">
|
|
<AddUrlScreen
|
|
onBack={() => (screen = 'home')}
|
|
onSubmit={submitUrl}
|
|
/>
|
|
</div>
|
|
|
|
<!-- ── Notifications screen ──────────────────────────────── -->
|
|
{:else if screen === 'notifications'}
|
|
<div class="ic-scroll screen-scroll">
|
|
<NotificationsScreen
|
|
onBack={() => (screen = 'home')}
|
|
sseConnected={connectionStatus === 'connected'}
|
|
{sseLastPing}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ── Recipe sheet overlay ─────────────────────────────── -->
|
|
{#if selectedItem}
|
|
<RecipeSheet
|
|
item={selectedItem}
|
|
onClose={() => (selectedItem = null)}
|
|
onRetry={(id) => { retryItem(id); selectedItem = null; }}
|
|
onDelete={(id) => { removeItem(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>
|