Add shareable search links with saved filters
Save a search and get a shareable URL at /share/<id> that preserves: - Search query - Selected stores - Filter text - Excluded stores - Sort column and direction Backend: - New shared_searches table (migration 005) - POST /api/share — saves search state, returns share_id - GET /api/share/:id — loads saved search config Frontend: - Share button appears on results page after search completes - Copies link to clipboard with confirmation message - /share/[id] page loads saved config, re-runs search with same store selection, and restores all filters - Shows "Shared" badge in header with "Search Again" link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -125,3 +125,18 @@ export function searchProductsStream(
|
|||||||
export function testStore(id: number, query: string) {
|
export function testStore(id: number, query: string) {
|
||||||
return api<any>(`/api/stores/${id}/test`, { method: 'POST', body: JSON.stringify({ query }) });
|
return api<any>(`/api/stores/${id}/test`, { method: 'POST', body: JSON.stringify({ query }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveSharedSearch(data: {
|
||||||
|
query: string;
|
||||||
|
storeIds?: string;
|
||||||
|
filterText?: string;
|
||||||
|
excludedStores?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortAsc?: boolean;
|
||||||
|
}) {
|
||||||
|
return api<any>('/api/share', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSharedSearch(shareId: string) {
|
||||||
|
return api<any>(`/api/share/${shareId}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { searchProductsStream } from '$lib/api';
|
import { searchProductsStream, saveSharedSearch } from '$lib/api';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
@@ -123,6 +123,29 @@
|
|||||||
excludedStores = next;
|
excludedStores = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shareMessage = $state('');
|
||||||
|
|
||||||
|
async function handleShare() {
|
||||||
|
try {
|
||||||
|
const params = $page.url.searchParams;
|
||||||
|
const shared = await saveSharedSearch({
|
||||||
|
query,
|
||||||
|
storeIds: params.get('stores') || undefined,
|
||||||
|
filterText: filterText || undefined,
|
||||||
|
excludedStores: excludedStores.size > 0 ? [...excludedStores].join(',') : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortAsc,
|
||||||
|
});
|
||||||
|
const shareUrl = `${window.location.origin}/share/${shared.share_id}`;
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
shareMessage = 'Link copied to clipboard!';
|
||||||
|
setTimeout(() => shareMessage = '', 3000);
|
||||||
|
} catch {
|
||||||
|
shareMessage = 'Failed to create share link';
|
||||||
|
setTimeout(() => shareMessage = '', 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function positionPreview(e) {
|
function positionPreview(e) {
|
||||||
const thumb = e.currentTarget;
|
const thumb = e.currentTarget;
|
||||||
const preview = thumb.querySelector('.thumb-preview');
|
const preview = thumb.querySelector('.thumb-preview');
|
||||||
@@ -161,7 +184,20 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="btn-secondary text-xs py-1">New Search</a>
|
<div class="flex items-center gap-2">
|
||||||
|
{#if shareMessage}
|
||||||
|
<span class="text-2xs text-emerald-400">{shareMessage}</span>
|
||||||
|
{/if}
|
||||||
|
{#if searchDone && results.length > 0}
|
||||||
|
<button onclick={handleShare} class="btn-secondary text-xs py-1 flex items-center gap-1.5">
|
||||||
|
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
|
||||||
|
</svg>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<a href="/" class="btn-secondary text-xs py-1">New Search</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Store Progress -->
|
<!-- Store Progress -->
|
||||||
|
|||||||
341
src/client/src/routes/share/[id]/+page.svelte
Normal file
341
src/client/src/routes/share/[id]/+page.svelte
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getSharedSearch, searchProductsStream, saveSharedSearch } from '$lib/api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let results = $state([]);
|
||||||
|
let meta = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let sortBy = $state('price');
|
||||||
|
let sortAsc = $state(true);
|
||||||
|
let filterText = $state('');
|
||||||
|
let excludedStores = $state(new Set());
|
||||||
|
|
||||||
|
let storeProgress = $state([]);
|
||||||
|
let searchDone = $state(false);
|
||||||
|
let loadError = $state('');
|
||||||
|
let shareMessage = $state('');
|
||||||
|
let sharedConfig = $state(null);
|
||||||
|
|
||||||
|
let cleanupStream = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const shared = await getSharedSearch($page.params.id);
|
||||||
|
sharedConfig = shared;
|
||||||
|
query = shared.query;
|
||||||
|
sortBy = shared.sort_by || 'price';
|
||||||
|
sortAsc = shared.sort_asc !== 0;
|
||||||
|
filterText = shared.filter_text || '';
|
||||||
|
|
||||||
|
if (shared.excluded_stores) {
|
||||||
|
excludedStores = new Set(shared.excluded_stores.split(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the search with saved store selection
|
||||||
|
doSearch(shared.store_ids || undefined);
|
||||||
|
} catch {
|
||||||
|
loadError = 'Shared search not found or has expired.';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { if (cleanupStream) cleanupStream(); };
|
||||||
|
});
|
||||||
|
|
||||||
|
function doSearch(storeIds) {
|
||||||
|
loading = true;
|
||||||
|
searchDone = false;
|
||||||
|
results = [];
|
||||||
|
meta = null;
|
||||||
|
storeProgress = [];
|
||||||
|
|
||||||
|
cleanupStream = searchProductsStream(query, {
|
||||||
|
stores: storeIds,
|
||||||
|
}, (event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'start':
|
||||||
|
storeProgress = event.stores.map((s) => ({
|
||||||
|
id: s.id, name: s.name, renderJs: s.renderJs,
|
||||||
|
status: 'searching', resultCount: 0, duration: 0, error: null,
|
||||||
|
}));
|
||||||
|
loading = false;
|
||||||
|
break;
|
||||||
|
case 'store_complete':
|
||||||
|
results = [...results, ...event.results];
|
||||||
|
storeProgress = storeProgress.map((s) =>
|
||||||
|
s.id === event.storeId
|
||||||
|
? { ...s, status: 'done', resultCount: event.resultCount, duration: event.duration }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'store_error':
|
||||||
|
storeProgress = storeProgress.map((s) =>
|
||||||
|
s.id === event.storeId
|
||||||
|
? { ...s, status: 'error', error: event.error, duration: event.duration }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
meta = event.meta;
|
||||||
|
searchDone = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let storeNames = $derived(() => {
|
||||||
|
const names = [...new Set(results.map(r => r.storeName))];
|
||||||
|
return names.sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
let filteredAndSorted = $derived(() => {
|
||||||
|
let items = [...results];
|
||||||
|
if (filterText) {
|
||||||
|
const lower = filterText.toLowerCase();
|
||||||
|
items = items.filter(r => r.name.toLowerCase().includes(lower));
|
||||||
|
}
|
||||||
|
if (excludedStores.size > 0) {
|
||||||
|
items = items.filter(r => !excludedStores.has(r.storeName));
|
||||||
|
}
|
||||||
|
items.sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'price': cmp = (a.price ?? Infinity) - (b.price ?? Infinity); break;
|
||||||
|
case 'name': cmp = a.name.localeCompare(b.name); break;
|
||||||
|
case 'store': cmp = a.storeName.localeCompare(b.storeName); break;
|
||||||
|
}
|
||||||
|
return sortAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSort(column) {
|
||||||
|
if (sortBy === column) { sortAsc = !sortAsc; } else { sortBy = column; sortAsc = column === 'name' || column === 'store'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStore(name) {
|
||||||
|
const next = new Set(excludedStores);
|
||||||
|
if (next.has(name)) next.delete(name); else next.add(name);
|
||||||
|
excludedStores = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPreview(e) {
|
||||||
|
const thumb = e.currentTarget;
|
||||||
|
const preview = thumb.querySelector('.thumb-preview');
|
||||||
|
if (!preview) return;
|
||||||
|
const rect = thumb.getBoundingClientRect();
|
||||||
|
preview.style.left = `${rect.right + 12}px`;
|
||||||
|
preview.style.top = `${rect.top + rect.height / 2}px`;
|
||||||
|
preview.style.transform = 'translateY(-50%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price, currency) {
|
||||||
|
if (price === null || price === undefined) return 'N/A';
|
||||||
|
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price); }
|
||||||
|
catch { return `${currency} ${price.toFixed(2)}`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortIcon(column) {
|
||||||
|
if (sortBy !== column) return '';
|
||||||
|
return sortAsc ? '\u2191' : '\u2193';
|
||||||
|
}
|
||||||
|
|
||||||
|
let completedCount = $derived(() => storeProgress.filter(s => s.status !== 'searching').length);
|
||||||
|
let totalStores = $derived(() => storeProgress.length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col max-w-[1400px] mx-auto w-full">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-2xs text-accent-text bg-accent-muted px-2 py-0.5 rounded">Shared</span>
|
||||||
|
<h1 class="text-sm font-semibold text-text-primary">Search results for:
|
||||||
|
<span class="text-accent-text">{query}</span>
|
||||||
|
</h1>
|
||||||
|
{#if meta}
|
||||||
|
<span class="text-2xs text-text-tertiary">
|
||||||
|
({meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms)
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if shareMessage}
|
||||||
|
<span class="text-2xs text-emerald-400">{shareMessage}</span>
|
||||||
|
{/if}
|
||||||
|
<a href="/results?q={encodeURIComponent(query)}" class="btn-secondary text-xs py-1">Search Again</a>
|
||||||
|
<a href="/" class="btn-secondary text-xs py-1">New Search</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
|
||||||
|
<svg class="w-10 h-10 mb-3 text-text-tertiary/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">{loadError}</p>
|
||||||
|
<a href="/" class="text-xs text-accent-text hover:text-accent mt-2">Go to search</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Store Progress -->
|
||||||
|
{#if storeProgress.length > 0}
|
||||||
|
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-xs font-medium text-text-secondary">
|
||||||
|
{#if searchDone}All stores completed{:else}Searching stores ({completedCount()}/{totalStores()}){/if}
|
||||||
|
</span>
|
||||||
|
{#if !searchDone}
|
||||||
|
<div class="flex-1 h-1 bg-surface-hover rounded-full overflow-hidden max-w-xs">
|
||||||
|
<div class="h-full bg-accent rounded-full transition-all duration-300"
|
||||||
|
style="width: {totalStores() > 0 ? (completedCount() / totalStores() * 100) : 0}%"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each storeProgress as store}
|
||||||
|
<div class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-all duration-300
|
||||||
|
{store.status === 'searching' ? 'bg-surface-raised border-surface-border text-text-secondary'
|
||||||
|
: store.status === 'done' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||||
|
: 'bg-red-500/10 border-red-500/20 text-red-400'}">
|
||||||
|
{#if store.status === 'searching'}
|
||||||
|
<svg class="w-3.5 h-3.5 animate-spin text-accent-text" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{:else if store.status === 'done'}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium">{store.name}</span>
|
||||||
|
{#if store.status === 'searching'}
|
||||||
|
<span class="text-text-tertiary">{store.renderJs ? 'rendering...' : 'fetching...'}</span>
|
||||||
|
{:else if store.status === 'done'}
|
||||||
|
<span>{store.resultCount} products</span>
|
||||||
|
<span class="text-emerald-600">{store.duration}ms</span>
|
||||||
|
{:else}
|
||||||
|
<span class="truncate max-w-[150px]" title={store.error}>{store.error}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
{#if results.length > 0}
|
||||||
|
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3 space-y-2.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
|
||||||
|
</svg>
|
||||||
|
<input type="text" bind:value={filterText} placeholder="Filter results..."
|
||||||
|
class="w-full pl-10 pr-4 py-2.5 bg-surface-raised border border-surface-border rounded-lg text-sm
|
||||||
|
text-text-primary placeholder-text-tertiary focus:border-accent/50 focus:ring-1 focus:ring-accent/20 focus:outline-none transition-all" />
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-text-tertiary whitespace-nowrap">{filteredAndSorted().length} of {results.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{#each storeNames() as name}
|
||||||
|
<button onclick={() => toggleStore(name)}
|
||||||
|
class="flex items-center gap-1.5 text-2xs px-2 py-1 rounded border transition-colors duration-100
|
||||||
|
{excludedStores.has(name) ? 'bg-surface border-surface-border text-text-tertiary line-through opacity-50 hover:opacity-75'
|
||||||
|
: 'bg-accent-muted border-accent/30 text-accent-text hover:bg-accent/20'}">
|
||||||
|
<span class="w-3 h-3 rounded-sm border flex items-center justify-center flex-shrink-0
|
||||||
|
{excludedStores.has(name) ? 'border-text-tertiary/30' : 'border-accent/50 bg-accent/20'}">
|
||||||
|
{#if !excludedStores.has(name)}
|
||||||
|
<svg class="w-2 h-2 text-accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
|
||||||
|
<svg class="w-8 h-8 animate-spin text-accent mb-3" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">Loading shared search...</p>
|
||||||
|
</div>
|
||||||
|
{:else if searchDone && results.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
|
||||||
|
<p class="text-sm">No results found for "{query}"</p>
|
||||||
|
</div>
|
||||||
|
{:else if results.length > 0}
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 z-10 bg-surface">
|
||||||
|
<tr class="border-b border-surface-border">
|
||||||
|
<th class="w-12 px-6 py-2.5"></th>
|
||||||
|
<th class="px-4 py-2.5 text-left">
|
||||||
|
<button onclick={() => handleSort('name')} class="text-2xs font-medium text-text-tertiary uppercase tracking-wider hover:text-text-secondary transition-colors">Product {sortIcon('name')}</button>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-2.5 text-right">
|
||||||
|
<button onclick={() => handleSort('price')} class="text-2xs font-medium text-text-tertiary uppercase tracking-wider hover:text-text-secondary transition-colors">Price {sortIcon('price')}</button>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">
|
||||||
|
<button onclick={() => handleSort('store')} class="text-2xs font-medium text-text-tertiary uppercase tracking-wider hover:text-text-secondary transition-colors">Store {sortIcon('store')}</button>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-2.5 text-right">
|
||||||
|
<span class="text-2xs font-medium text-text-tertiary uppercase tracking-wider">Link</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredAndSorted() as product}
|
||||||
|
<tr class="border-b border-surface-border hover:bg-surface-hover/50 transition-colors duration-100 group">
|
||||||
|
<td class="px-6 py-2">
|
||||||
|
{#if product.image}
|
||||||
|
<div class="relative thumb-hover" onmouseenter={positionPreview}>
|
||||||
|
<div class="w-8 h-8 rounded bg-surface flex items-center justify-center overflow-hidden cursor-pointer">
|
||||||
|
<img src={product.image} alt="" class="max-w-full max-h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div class="thumb-preview pointer-events-none">
|
||||||
|
<div class="w-80 max-h-80 rounded-lg bg-surface-raised border border-surface-border shadow-2xl shadow-black/50 p-5">
|
||||||
|
<img src={product.image} alt={product.name} class="max-w-full max-h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-8 h-8 rounded bg-surface flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-text-tertiary/20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="text-sm text-text-primary group-hover:text-white transition-colors line-clamp-1">{product.name}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<span class="text-sm font-semibold text-accent-text whitespace-nowrap">{formatPrice(product.price, product.currency)}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 whitespace-nowrap">
|
||||||
|
<span class="text-2xs text-text-tertiary bg-surface px-1.5 py-0.5 rounded">{product.storeName}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-2 text-right whitespace-nowrap">
|
||||||
|
<a href={product.url} target="_blank" rel="noopener noreferrer"
|
||||||
|
class="text-2xs text-accent-text hover:text-accent transition-colors opacity-0 group-hover:opacity-100">Open →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
13
src/server/db/migrations/005_add_shared_searches.sql
Normal file
13
src/server/db/migrations/005_add_shared_searches.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE shared_searches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id TEXT NOT NULL UNIQUE,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
store_ids TEXT,
|
||||||
|
filter_text TEXT,
|
||||||
|
excluded_stores TEXT,
|
||||||
|
sort_by TEXT DEFAULT 'price',
|
||||||
|
sort_asc INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_shared_searches_share_id ON shared_searches(share_id);
|
||||||
@@ -14,6 +14,7 @@ import { categoryRoutes } from './routes/categories.js';
|
|||||||
import { searchRoutes } from './routes/search.js';
|
import { searchRoutes } from './routes/search.js';
|
||||||
import { testRoutes } from './routes/test.js';
|
import { testRoutes } from './routes/test.js';
|
||||||
import { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
|
import { shareRoutes } from './routes/share.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ await app.register(categoryRoutes, { prefix: '/api' });
|
|||||||
await app.register(searchRoutes, { prefix: '/api' });
|
await app.register(searchRoutes, { prefix: '/api' });
|
||||||
await app.register(testRoutes, { prefix: '/api' });
|
await app.register(testRoutes, { prefix: '/api' });
|
||||||
await app.register(healthRoutes, { prefix: '/api' });
|
await app.register(healthRoutes, { prefix: '/api' });
|
||||||
|
await app.register(shareRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
// Serve static frontend in production
|
// Serve static frontend in production
|
||||||
if (config.isProduction) {
|
if (config.isProduction) {
|
||||||
|
|||||||
51
src/server/models/shared-search.ts
Normal file
51
src/server/models/shared-search.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { getDatabase, saveDatabase } from '../db/connection.js';
|
||||||
|
import { queryOne } from '../db/query.js';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export interface SharedSearch {
|
||||||
|
id: number;
|
||||||
|
share_id: string;
|
||||||
|
query: string;
|
||||||
|
store_ids: string | null;
|
||||||
|
filter_text: string | null;
|
||||||
|
excluded_stores: string | null;
|
||||||
|
sort_by: string;
|
||||||
|
sort_asc: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateShareId(): string {
|
||||||
|
return crypto.randomBytes(6).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSharedSearch(data: {
|
||||||
|
query: string;
|
||||||
|
storeIds?: string;
|
||||||
|
filterText?: string;
|
||||||
|
excludedStores?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortAsc?: boolean;
|
||||||
|
}): SharedSearch {
|
||||||
|
const db = getDatabase();
|
||||||
|
const shareId = generateShareId();
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO shared_searches (share_id, query, store_ids, filter_text, excluded_stores, sort_by, sort_asc)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [
|
||||||
|
shareId,
|
||||||
|
data.query,
|
||||||
|
data.storeIds || null,
|
||||||
|
data.filterText || null,
|
||||||
|
data.excludedStores || null,
|
||||||
|
data.sortBy || 'price',
|
||||||
|
data.sortAsc !== false ? 1 : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
saveDatabase();
|
||||||
|
return getSharedSearch(shareId) as SharedSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSharedSearch(shareId: string): SharedSearch | undefined {
|
||||||
|
return queryOne('SELECT * FROM shared_searches WHERE share_id = ?', [shareId]);
|
||||||
|
}
|
||||||
41
src/server/routes/share.ts
Normal file
41
src/server/routes/share.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { createSharedSearch, getSharedSearch } from '../models/shared-search.js';
|
||||||
|
|
||||||
|
export const shareRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
// Save a search
|
||||||
|
app.post<{
|
||||||
|
Body: {
|
||||||
|
query: string;
|
||||||
|
storeIds?: string;
|
||||||
|
filterText?: string;
|
||||||
|
excludedStores?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortAsc?: boolean;
|
||||||
|
};
|
||||||
|
}>('/share', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['query'],
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', minLength: 1 },
|
||||||
|
storeIds: { type: 'string' },
|
||||||
|
filterText: { type: 'string' },
|
||||||
|
excludedStores: { type: 'string' },
|
||||||
|
sortBy: { type: 'string' },
|
||||||
|
sortAsc: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const shared = createSharedSearch(request.body);
|
||||||
|
return reply.code(201).send(shared);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load a shared search
|
||||||
|
app.get<{ Params: { id: string } }>('/share/:id', async (request, reply) => {
|
||||||
|
const shared = getSharedSearch(request.params.id);
|
||||||
|
if (!shared) return reply.code(404).send({ error: 'Shared search not found' });
|
||||||
|
return shared;
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user