Files
insta-recipe/src/routes/components/QueueItemCard.svelte
Giancarmine Salucci 8545744bb1 fix(ssr): resolve EventSource SSR violations and implement best practices
- Fix EventSource is not defined error in queue dashboard
- Add browser guards for all EventSource usage
- Replace static constants (EventSource.OPEN/CLOSED) with numeric values
- Fix setInterval SSR violation in LLM health indicator
- Replace $effect anti-pattern with onMount in share page
- Add comprehensive SvelteKit SSR best practices documentation
- Add SSR audit and testing verification

All changes follow SvelteKit best practices and are verified against
official documentation. Production build succeeds with no SSR errors.

Closes: FixEventSourceSSR
See: docs/outcomes/FixEventSourceSSR.md
2025-12-22 03:00:29 +01:00

295 lines
9.9 KiB
Svelte

<script lang="ts">
import type { QueueItem } from '$lib/server/queue/types';
import { formatDistanceToNow } from 'date-fns';
import { onMount, onDestroy } from 'svelte';
import { serviceWorkerMessageHandler } from '$lib/client/ServiceWorkerMessageHandler';
interface Props {
item: QueueItem;
highlighted?: boolean;
onRetry?: () => void;
onRemove?: () => void;
onClearHighlight?: () => void;
}
let { item, highlighted = false, onRetry, onRemove, onClearHighlight }: Props = $props();
onMount(() => {
// Register retry callback with service worker handler
if (onRetry) {
serviceWorkerMessageHandler.registerRetryCallback(item.id, onRetry);
}
});
onDestroy(() => {
// Unregister retry callback
serviceWorkerMessageHandler.unregisterRetryCallback(item.id);
});
// Status badge styling
function getStatusBadge(status: QueueItem['status']) {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'success':
return 'bg-green-100 text-green-800 border-green-200';
case 'error':
case 'unhealthy':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
}
// Phase progress indicators
function getPhaseIcon(phase: { name: string; status: string; startedAt?: string; completedAt?: string }) {
switch (phase.status) {
case 'completed':
return '✅';
case 'in_progress':
return '🔄';
case 'error':
return '❌';
default:
return '⏳';
}
}
// Format relative time
function getRelativeTime(timestamp?: string) {
if (!timestamp) return '';
try {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
} catch {
return timestamp;
}
}
// Extract Instagram username from URL
function getInstagramUsername(url: string) {
try {
const matches = url.match(/instagram\.com\/([^\/\?]+)/);
return matches?.[1] ? `@${matches[1]}` : null;
} catch {
return null;
}
}
// Calculate overall progress percentage
function getProgressPercentage() {
if (!item.phases || item.phases.length === 0) return 0;
const completedPhases = item.phases.filter(phase => phase.status === 'completed').length;
return Math.round((completedPhases / item.phases.length) * 100);
}
// Clear highlight when card is clicked
function handleCardClick() {
if (highlighted && onClearHighlight) {
onClearHighlight();
}
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="bg-white border rounded-lg shadow-sm hover:shadow-md transition-shadow p-6 {highlighted ? 'ring-2 ring-blue-500 border-blue-300' : ''}"
data-queue-item={item.id}
onclick={handleCardClick}
role={highlighted ? 'button' : undefined}
tabindex={highlighted ? 0 : -1}
>
<!-- Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<!-- URL and Username -->
<div class="flex items-center space-x-2 mb-2">
<div class="text-sm text-gray-500 truncate">{item.url}</div>
{#if getInstagramUsername(item.url)}
<span class="text-sm text-blue-600 font-medium">{getInstagramUsername(item.url)}</span>
{/if}
</div>
<!-- Status and Time -->
<div class="flex items-center space-x-3">
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium border {getStatusBadge(item.status)}">
{item.status.replace('_', ' ').toUpperCase()}
</span>
<span class="text-xs text-gray-500">
Created {getRelativeTime(item.createdAt)}
</span>
{#if item.updatedAt && item.updatedAt !== item.createdAt}
<span class="text-xs text-gray-500">
• Updated {getRelativeTime(item.updatedAt)}
</span>
{/if}
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-2 ml-4">
{#if item.status === 'error' || item.status === 'unhealthy'}
<button
onclick={(e) => { e.stopPropagation(); onRetry?.(); }}
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
title="Retry processing"
>
<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="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>
{/if}
<button
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
title="Remove from queue"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</div>
<!-- Progress Bar (for in-progress items) -->
{#if item.status === 'in_progress' && item.phases && item.phases.length > 0}
<div class="mb-4">
<div class="flex justify-between text-xs text-gray-600 mb-1">
<span>Processing Progress</span>
<span>{getProgressPercentage()}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {getProgressPercentage()}%"
></div>
</div>
</div>
{/if}
<!-- Processing Phases -->
{#if item.phases && item.phases.length > 0}
<div class="mb-4">
<div class="text-sm font-medium text-gray-700 mb-2">Processing Phases</div>
<div class="space-y-2">
{#each item.phases as phase}
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<span class="text-lg">{getPhaseIcon(phase)}</span>
<span class="text-gray-700 capitalize">{phase.name.replace('_', ' ')}</span>
</div>
<div class="text-xs text-gray-500">
{#if phase.status === 'completed' && phase.completedAt}
{getRelativeTime(phase.completedAt)}
{:else if phase.status === 'in_progress' && phase.startedAt}
Started {getRelativeTime(phase.startedAt)}
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Error Message -->
{#if item.error}
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-start space-x-2">
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" 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>
<div>
<div class="text-sm font-medium text-red-800">Processing Error</div>
<div class="text-sm text-red-700 mt-1">{item.error}</div>
</div>
</div>
</div>
{/if}
<!-- Results (for successful items) -->
{#if item.status === 'success' && item.results}
<div class="border-t pt-4">
<div class="text-sm font-medium text-gray-700 mb-3">Extraction Results</div>
{#if item.results.recipe}
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-start space-x-3">
<!-- Recipe Image Thumbnail -->
{#if item.results.recipe.image}
<img
src={item.results.recipe.image}
alt="Recipe thumbnail"
class="w-16 h-16 object-cover rounded-lg flex-shrink-0"
loading="lazy"
/>
{/if}
<div class="flex-1 min-w-0">
<!-- Recipe Title -->
{#if item.results.recipe.name}
<h4 class="text-sm font-medium text-gray-900 mb-1 truncate">
{item.results.recipe.name}
</h4>
{/if}
<!-- Recipe Details -->
<div class="text-xs text-gray-600 space-y-1">
{#if item.results.recipe.servings}
<div>Servings: {item.results.recipe.servings}</div>
{/if}
{#if item.results.recipe.keywords && item.results.recipe.keywords.length > 0}
<div class="flex flex-wrap gap-1">
{#each item.results.recipe.keywords.slice(0, 3) as keyword}
<span class="inline-block px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">
{keyword}
</span>
{/each}
{#if item.results.recipe.keywords.length > 3}
<span class="text-xs text-gray-500">+{item.results.recipe.keywords.length - 3} more</span>
{/if}
</div>
{/if}
</div>
</div>
</div>
<!-- Tandoor Link -->
{#if item.results.tandoorUrl}
<div class="mt-3 pt-3 border-t border-green-200">
<a
href={item.results.tandoorUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center space-x-2 text-sm text-green-700 hover:text-green-800 font-medium"
onclick={(e) => e.stopPropagation()}
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
<span>View in Tandoor</span>
</a>
</div>
{/if}
</div>
{:else}
<div class="text-sm text-gray-600">
Processing completed successfully but no detailed results available.
</div>
{/if}
</div>
{/if}
<!-- Highlighted Item Notice -->
{#if highlighted}
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-center space-x-2 text-sm text-blue-800">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>This item was just added to the queue</span>
</div>
</div>
{/if}
</div>