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
This commit is contained in:
176
src/routes/components/NotificationSettings.svelte
Normal file
176
src/routes/components/NotificationSettings.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pushNotificationManager, type NotificationState } from '$lib/client/PushNotificationManager';
|
||||
|
||||
let state = $state<NotificationState>({
|
||||
supported: false,
|
||||
permission: 'default',
|
||||
subscribed: false,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
// Subscribe to state changes
|
||||
unsubscribe = pushNotificationManager.onStateChange((newState) => {
|
||||
state = newState;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
});
|
||||
|
||||
async function handleToggle() {
|
||||
await pushNotificationManager.toggleSubscription();
|
||||
}
|
||||
|
||||
function getStatusText(): string {
|
||||
if (!state.supported) return 'Not supported';
|
||||
if (state.permission === 'denied') return 'Permission denied';
|
||||
if (state.subscribed) return 'Enabled';
|
||||
if (state.permission === 'granted') return 'Available';
|
||||
return 'Permission needed';
|
||||
}
|
||||
|
||||
function getStatusColor(): string {
|
||||
if (!state.supported || state.permission === 'denied') return 'text-red-600';
|
||||
if (state.subscribed) return 'text-green-600';
|
||||
return 'text-yellow-600';
|
||||
}
|
||||
|
||||
function getButtonText(): string {
|
||||
if (state.loading) return 'Working...';
|
||||
if (state.subscribed) return 'Disable Notifications';
|
||||
return 'Enable Notifications';
|
||||
}
|
||||
|
||||
function canToggle(): boolean {
|
||||
return state.supported && state.permission !== 'denied' && !state.loading;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-white border rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<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>
|
||||
<h3 class="text-lg font-medium text-gray-900">Push Notifications</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Get notified when your recipe extractions complete, even when InstaRecipe is not open.
|
||||
</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<span class="text-sm text-gray-500">Status:</span>
|
||||
<span class="text-sm font-medium {getStatusColor()}">
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if state.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-4 h-4 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">Error</div>
|
||||
<div class="text-sm text-red-700">{state.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Browser Support Info -->
|
||||
{#if !state.supported}
|
||||
<div class="mb-4 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<svg class="w-4 h-4 text-gray-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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800">Not Supported</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
Your browser doesn't support push notifications or the site is not running over HTTPS.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Permission Denied Info -->
|
||||
{#if state.permission === 'denied'}
|
||||
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<svg class="w-4 h-4 text-yellow-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-yellow-800">Permission Denied</div>
|
||||
<div class="text-sm text-yellow-700">
|
||||
You've blocked notifications for this site. Please enable them in your browser settings to receive updates.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Features List -->
|
||||
{#if state.supported && state.permission !== 'denied'}
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-600 mb-2">You'll receive notifications for:</div>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li class="flex items-center space-x-2">
|
||||
<svg class="w-3 h-3 text-green-500" 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>✅ Successful recipe extractions</span>
|
||||
</li>
|
||||
<li class="flex items-center space-x-2">
|
||||
<svg class="w-3 h-3 text-green-500" 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>❌ Failed extractions (with retry option)</span>
|
||||
</li>
|
||||
<li class="flex items-center space-x-2">
|
||||
<svg class="w-3 h-3 text-green-500" 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>🔗 Direct links to view in Tandoor</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Toggle Button -->
|
||||
<div class="ml-6">
|
||||
<button
|
||||
onclick={handleToggle}
|
||||
disabled={!canToggle()}
|
||||
class="flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors {state.subscribed
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200 disabled:opacity-50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50'} disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if state.loading}
|
||||
<svg class="w-4 h-4 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>
|
||||
{: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={state.subscribed ? "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" : "M15 17h5l-5 5-5-5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"}></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{getButtonText()}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
295
src/routes/components/QueueItemCard.svelte
Normal file
295
src/routes/components/QueueItemCard.svelte
Normal file
@@ -0,0 +1,295 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user