- Extract 8 reusable components from monolithic share page - Add LLM health indicator with 30s polling - Implement stealth thumbnail extraction with 4-method cascade - Integrate real-time thumbnail preview component - Reduce share page from 306 to ~140 lines - Add comprehensive outcome documentation Components: - UrlInputSection: URL input and extraction trigger - ProgressIndicator: Loading state display - ExtractedTextViewer: Collapsible text preview - RecipeCard: Recipe display with Tandoor integration - ErrorState: Error handling UI - LogViewer: System logs with color coding - LlmHealthIndicator: LLM status with polling - ThumbnailPreview: Real-time thumbnail display Thumbnail Methods: 1. Meta tag extraction (og:image, twitter:image) 2. Video poster attribute 3. Instagram embedded JSON data 4. Screenshot fallback Stories Completed: - Story 1: Component extraction and refactoring - Story 2: LLM health status indicator - Story 3: Enhanced stealth thumbnail extraction - Story 4: Thumbnail preview integration Closes: RefactorSharePageAndEnhanceThumbnails
201 lines
6.0 KiB
Svelte
201 lines
6.0 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/stores';
|
||
import type { ProgressEvent } from '$lib/server/extraction';
|
||
import UrlInputSection from './components/UrlInputSection.svelte';
|
||
import ProgressIndicator from './components/ProgressIndicator.svelte';
|
||
import ExtractedTextViewer from './components/ExtractedTextViewer.svelte';
|
||
import RecipeCard from './components/RecipeCard.svelte';
|
||
import ErrorState from './components/ErrorState.svelte';
|
||
import LogViewer from './components/LogViewer.svelte';
|
||
import LlmHealthIndicator from './components/LlmHealthIndicator.svelte';
|
||
import ThumbnailPreview from './components/ThumbnailPreview.svelte';
|
||
|
||
let status = $state('idle');
|
||
let logs = $state<string[]>([]);
|
||
let recipe = $state<any>(null);
|
||
let bodyText = $state<string>('');
|
||
let tandoorEnabled = $state(false);
|
||
let tandoorImporting = $state(false);
|
||
let tandoorError = $state<string | null>(null);
|
||
let currentMethod = $state<string>('');
|
||
let thumbnail = $state<string | null>(null);
|
||
let thumbnailStatus = $state<'idle' | 'extracting' | 'success' | 'error'>('idle');
|
||
|
||
// URL param parsing for Share Target
|
||
// Instagram typically shares text that contains the URL, so we might need to parse it out
|
||
let sharedText = $derived($page.url.searchParams.get('text') || '');
|
||
let sharedUrl = $derived($page.url.searchParams.get('url') || '');
|
||
|
||
function extractUrl(text: string) {
|
||
const match = text.match(/(https?:\/\/[^\s]+)/);
|
||
return match ? match[0] : null;
|
||
}
|
||
|
||
let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
|
||
|
||
$effect.pre(() => {
|
||
loadTandoorConfig();
|
||
});
|
||
|
||
// Load Tandoor config on mount
|
||
async function loadTandoorConfig() {
|
||
try {
|
||
const res = await fetch('/api/tandoor-config');
|
||
const config = await res.json();
|
||
tandoorEnabled = config.enabled;
|
||
logs = [...logs, `Tandoor integration ${config.enabled ? 'enabled' : 'disabled'}`];
|
||
} catch (e) {
|
||
logs = [...logs, 'Failed to load Tandoor config'];
|
||
}
|
||
}
|
||
|
||
// Map method names to icons
|
||
function getMethodIcon(method?: string): string {
|
||
const icons: Record<string, string> = {
|
||
'embedded-json': '📦',
|
||
'dom-selector': '🎯',
|
||
'graphql-api': '🔌',
|
||
legacy: '📄'
|
||
};
|
||
return method ? icons[method] || '⚙️' : '⚙️';
|
||
}
|
||
|
||
async function process() {
|
||
if (!targetUrl) return;
|
||
status = 'extracting';
|
||
thumbnailStatus = 'extracting';
|
||
logs = [...logs, '🚀 Starting extraction from: ' + targetUrl];
|
||
currentMethod = '';
|
||
|
||
try {
|
||
const response = await fetch('/api/extract-stream', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ url: targetUrl }),
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
if (!response.body) {
|
||
throw new Error('No response body');
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
|
||
const eventMatch = line.match(/^event: (\w+)\ndata: (.+)$/s);
|
||
if (!eventMatch) continue;
|
||
|
||
const [, eventType, eventData] = eventMatch;
|
||
const event: ProgressEvent = JSON.parse(eventData);
|
||
|
||
// Update UI based on event type
|
||
if (event.type === 'method') {
|
||
currentMethod = event.method || '';
|
||
logs = [...logs, `${getMethodIcon(event.method)} ${event.message}`];
|
||
} else if (event.type === 'status') {
|
||
logs = [...logs, `ℹ️ ${event.message}`];
|
||
} else if (event.type === 'retry') {
|
||
logs = [...logs, `🔄 ${event.message}`];
|
||
} else if (event.type === 'error') {
|
||
logs = [...logs, `❌ ${event.message}`];
|
||
} else if (event.type === 'thumbnail') {
|
||
thumbnail = event.data?.thumbnail || null;
|
||
thumbnailStatus = thumbnail ? 'success' : 'error';
|
||
logs = [...logs, `🎨 ${event.message}`];
|
||
} else if (eventType === 'complete' && event.data) {
|
||
recipe = event.data.recipe;
|
||
bodyText = event.data.recipe?.bodyText || '';
|
||
status = 'done';
|
||
logs = [...logs, `✅ ${event.message}`];
|
||
currentMethod = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
if (status !== 'done') {
|
||
status = 'error';
|
||
if (thumbnailStatus === 'extracting') {
|
||
thumbnailStatus = 'error';
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logs = [...logs, '❌ Network Error: ' + (e instanceof Error ? e.message : 'Unknown')];
|
||
status = 'error';
|
||
thumbnailStatus = 'error';
|
||
}
|
||
}
|
||
|
||
async function retry() {
|
||
recipe = null;
|
||
bodyText = '';
|
||
status = 'idle';
|
||
logs = [...logs, 'Retrying extraction...'];
|
||
await process();
|
||
}
|
||
|
||
async function importToTandoor() {
|
||
if (!recipe) return;
|
||
|
||
tandoorImporting = true;
|
||
tandoorError = null;
|
||
logs = [...logs, 'Importing recipe to Tandoor...'];
|
||
|
||
try {
|
||
const res = await fetch('/api/tandoor', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ recipe }),
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
logs = [...logs, `✓ Recipe imported successfully (ID: ${data.recipeId})`];
|
||
tandoorError = null;
|
||
} else {
|
||
logs = [...logs, `✗ Import failed: ${data.error}`];
|
||
tandoorError = data.error;
|
||
}
|
||
} catch (e) {
|
||
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
|
||
logs = [...logs, `✗ Network error: ${errorMsg}`];
|
||
tandoorError = errorMsg;
|
||
} finally {
|
||
tandoorImporting = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="p-8 max-w-lg mx-auto space-y-4">
|
||
<div class="flex items-center justify-between">
|
||
<h1 class="text-2xl font-bold">InstaChef PWA</h1>
|
||
<LlmHealthIndicator />
|
||
</div>
|
||
|
||
<UrlInputSection {targetUrl} {sharedText} {sharedUrl} {status} onProcess={process} />
|
||
<ProgressIndicator {status} />
|
||
<ThumbnailPreview {thumbnail} status={thumbnailStatus} />
|
||
<ExtractedTextViewer {bodyText} />
|
||
<RecipeCard
|
||
{recipe}
|
||
{tandoorEnabled}
|
||
{tandoorImporting}
|
||
{tandoorError}
|
||
onRetry={retry}
|
||
onImportToTandoor={importToTandoor}
|
||
/>
|
||
<ErrorState {status} {bodyText} onRetry={retry} />
|
||
<LogViewer {logs} {currentMethod} {status} />
|
||
</div> |