- 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
295 lines
9.9 KiB
Svelte
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> |