Files
insta-recipe/src/routes/+page.svelte
Giancarmine Salucci 561c2843b1
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m1s
feat(ui): add delete button to RecipeSheet + fix NaNd ago + full QueueItem in POST response
- 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>
2026-05-12 23:05:44 +02:00

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>