Replace results cards with sortable, filterable table
- Compact table view with thumbnail, product name, price, store, link - Click column headers to sort (toggles asc/desc) - Filter bar to search within results by product name - Store dropdown filter when multiple stores present - Sticky header, hover-reveal "Open" link, sort direction arrows - Shows "X of Y shown" count when filtering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@
|
|||||||
let meta = $state(null);
|
let meta = $state(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let sortBy = $state('price');
|
let sortBy = $state('price');
|
||||||
|
let sortAsc = $state(true);
|
||||||
|
let filterText = $state('');
|
||||||
|
let filterStore = $state('');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const params = $page.url.searchParams;
|
const params = $page.url.searchParams;
|
||||||
@@ -44,15 +47,53 @@
|
|||||||
doSearch();
|
doSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
let sortedResults = $derived(() => {
|
function handleSort(column) {
|
||||||
const sorted = [...results];
|
if (sortBy === column) {
|
||||||
switch (sortBy) {
|
sortAsc = !sortAsc;
|
||||||
case 'price': sorted.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity)); break;
|
} else {
|
||||||
case 'price-desc': sorted.sort((a, b) => (b.price ?? -Infinity) - (a.price ?? -Infinity)); break;
|
sortBy = column;
|
||||||
case 'name': sorted.sort((a, b) => a.name.localeCompare(b.name)); break;
|
sortAsc = column === 'name' || column === 'store';
|
||||||
case 'store': sorted.sort((a, b) => a.storeName.localeCompare(b.storeName)); break;
|
|
||||||
}
|
}
|
||||||
return sorted;
|
}
|
||||||
|
|
||||||
|
// Unique store names for the filter dropdown
|
||||||
|
let storeNames = $derived(() => {
|
||||||
|
const names = [...new Set(results.map(r => r.storeName))];
|
||||||
|
return names.sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
let filteredAndSorted = $derived(() => {
|
||||||
|
let items = [...results];
|
||||||
|
|
||||||
|
// Text filter
|
||||||
|
if (filterText) {
|
||||||
|
const lower = filterText.toLowerCase();
|
||||||
|
items = items.filter(r => r.name.toLowerCase().includes(lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store filter
|
||||||
|
if (filterStore) {
|
||||||
|
items = items.filter(r => r.storeName === filterStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
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 formatPrice(price, currency) {
|
function formatPrice(price, currency) {
|
||||||
@@ -60,6 +101,11 @@
|
|||||||
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price); }
|
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price); }
|
||||||
catch { return `${currency} ${price.toFixed(2)}`; }
|
catch { return `${currency} ${price.toFixed(2)}`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortIcon(column) {
|
||||||
|
if (sortBy !== column) return '';
|
||||||
|
return sortAsc ? '\u2191' : '\u2193';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
@@ -71,25 +117,17 @@
|
|||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" bind:value={query} placeholder="Search..."
|
<input type="text" bind:value={query} placeholder="Search products..."
|
||||||
class="w-full pl-9 pr-3 py-1.5 bg-surface-raised border border-surface-border rounded text-sm
|
class="w-full pl-9 pr-3 py-1.5 bg-surface-raised border border-surface-border rounded text-sm
|
||||||
text-text-primary placeholder-text-tertiary focus:border-accent/50 focus:outline-none transition-colors" />
|
text-text-primary placeholder-text-tertiary focus:border-accent/50 focus:outline-none transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
{#if meta}
|
||||||
{#if meta}
|
<span class="text-2xs text-text-tertiary whitespace-nowrap">
|
||||||
<span class="text-2xs text-text-tertiary">
|
{meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms
|
||||||
{meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms
|
</span>
|
||||||
</span>
|
{/if}
|
||||||
{/if}
|
|
||||||
<select bind:value={sortBy} class="input-field w-auto text-xs py-1 px-2">
|
|
||||||
<option value="price">Price: Low to High</option>
|
|
||||||
<option value="price-desc">Price: High to Low</option>
|
|
||||||
<option value="name">Name</option>
|
|
||||||
<option value="store">Store</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Errors -->
|
<!-- Errors -->
|
||||||
@@ -103,19 +141,43 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filters row -->
|
||||||
|
{#if !loading && results.length > 0}
|
||||||
|
<div class="flex-shrink-0 border-b border-surface-border px-6 py-2 flex items-center gap-3">
|
||||||
|
<div class="relative flex-1 max-w-xs">
|
||||||
|
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 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-8 pr-3 py-1 bg-surface border border-surface-border rounded text-xs
|
||||||
|
text-text-primary placeholder-text-tertiary focus:border-accent/50 focus:outline-none transition-colors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if storeNames().length > 1}
|
||||||
|
<select bind:value={filterStore} class="input-field w-auto text-xs py-1 px-2">
|
||||||
|
<option value="">All stores</option>
|
||||||
|
{#each storeNames() as name}
|
||||||
|
<option value={name}>{name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="text-2xs text-text-tertiary">
|
||||||
|
{filteredAndSorted().length} of {results.length} shown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
<div class="flex-1 overflow-auto">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div class="p-6 space-y-2">
|
||||||
{#each Array(8) as _}
|
{#each Array(10) as _}
|
||||||
<div class="card p-4 animate-pulse">
|
<div class="bg-surface-raised h-10 rounded animate-pulse"></div>
|
||||||
<div class="bg-surface-hover h-32 rounded mb-3"></div>
|
|
||||||
<div class="bg-surface-hover h-3.5 rounded w-3/4 mb-2"></div>
|
|
||||||
<div class="bg-surface-hover h-5 rounded w-1/3"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if sortedResults().length === 0}
|
{:else if results.length === 0}
|
||||||
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
|
<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">
|
<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="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
@@ -124,33 +186,83 @@
|
|||||||
<p class="text-xs mt-1">Try a different search term or check your store configurations.</p>
|
<p class="text-xs mt-1">Try a different search term or check your store configurations.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<table class="w-full">
|
||||||
{#each sortedResults() as product}
|
<thead class="sticky top-0 z-10 bg-surface">
|
||||||
<a href={product.url} target="_blank" rel="noopener noreferrer"
|
<tr class="border-b border-surface-border">
|
||||||
class="card overflow-hidden group hover:border-surface-border-hover transition-colors duration-150">
|
<th class="w-12 px-6 py-2.5"></th>
|
||||||
{#if product.image}
|
<th class="px-4 py-2.5 text-left">
|
||||||
<div class="h-32 bg-surface flex items-center justify-center overflow-hidden">
|
<button onclick={() => handleSort('name')}
|
||||||
<img src={product.image} alt={product.name}
|
class="text-2xs font-medium text-text-tertiary uppercase tracking-wider hover:text-text-secondary transition-colors">
|
||||||
class="max-h-full max-w-full object-contain p-3 group-hover:scale-105 transition-transform duration-200" />
|
Product {sortIcon('name')}
|
||||||
</div>
|
</button>
|
||||||
{:else}
|
</th>
|
||||||
<div class="h-32 bg-surface flex items-center justify-center">
|
<th class="px-4 py-2.5 text-right">
|
||||||
<svg class="w-8 h-8 text-text-tertiary/30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
<button onclick={() => handleSort('price')}
|
||||||
<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" />
|
class="text-2xs font-medium text-text-tertiary uppercase tracking-wider hover:text-text-secondary transition-colors">
|
||||||
</svg>
|
Price {sortIcon('price')}
|
||||||
</div>
|
</button>
|
||||||
{/if}
|
</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, i}
|
||||||
|
<tr class="border-b border-surface-border hover:bg-surface-hover/50 transition-colors duration-100 group">
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<td class="px-6 py-2">
|
||||||
|
{#if product.image}
|
||||||
|
<div class="w-8 h-8 rounded bg-surface flex items-center justify-center overflow-hidden">
|
||||||
|
<img src={product.image} alt="" class="max-w-full max-h-full object-contain" />
|
||||||
|
</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>
|
||||||
|
|
||||||
<div class="p-3">
|
<!-- Product name -->
|
||||||
<h3 class="text-xs font-medium text-text-primary line-clamp-2 mb-2 leading-relaxed">{product.name}</h3>
|
<td class="px-4 py-2">
|
||||||
<div class="flex items-center justify-between">
|
<span class="text-sm text-text-primary group-hover:text-white transition-colors line-clamp-1">
|
||||||
<span class="text-sm font-semibold text-accent-text">{formatPrice(product.price, product.currency)}</span>
|
{product.name}
|
||||||
<span class="text-2xs text-text-tertiary bg-surface px-1.5 py-0.5 rounded">{product.storeName}</span>
|
</span>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
|
||||||
</a>
|
<!-- Price -->
|
||||||
{/each}
|
<td class="px-4 py-2 text-right">
|
||||||
</div>
|
<span class="text-sm font-semibold text-accent-text whitespace-nowrap">
|
||||||
|
{formatPrice(product.price, product.currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Store -->
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="text-2xs text-text-tertiary bg-surface px-1.5 py-0.5 rounded">
|
||||||
|
{product.storeName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Link -->
|
||||||
|
<td class="px-6 py-2 text-right">
|
||||||
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user