Restyle entire frontend with Linear-inspired dark design

- Dark-first design with #0a0a0b backgrounds and subtle borders
- Purple/violet accent colors for primary actions
- Inter font with custom design tokens
- Sidebar navigation replacing top nav bar
- Compact, information-dense tables and cards
- Consistent component classes (btn-primary, input-field, card, label)
- Custom scrollbar and selection styling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mariosemes
2026-03-26 21:14:14 +01:00
parent 26467a6368
commit 98e326266f
11 changed files with 758 additions and 767 deletions

View File

@@ -2,12 +2,61 @@
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@layer base {
body {
@apply bg-surface text-text-primary font-sans antialiased;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
body {
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
::selection {
@apply bg-accent/30;
}
/* Scrollbar */
::-webkit-scrollbar {
@apply w-1.5;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-white/10 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-white/20;
}
}
@layer components {
.btn-primary {
@apply bg-accent hover:bg-accent-hover text-white px-3.5 py-1.5 rounded text-sm font-medium transition-colors duration-150;
}
.btn-secondary {
@apply bg-surface-raised hover:bg-surface-hover text-text-secondary hover:text-text-primary
border border-surface-border hover:border-surface-border-hover
px-3.5 py-1.5 rounded text-sm font-medium transition-all duration-150;
}
.btn-danger {
@apply text-red-400 hover:text-red-300 hover:bg-red-500/10 px-2 py-1 rounded text-sm transition-colors duration-150;
}
.input-field {
@apply w-full px-3 py-2 bg-surface-raised border border-surface-border rounded text-sm
text-text-primary placeholder-text-tertiary
focus:border-accent/50 focus:ring-1 focus:ring-accent/20 focus:outline-none
transition-colors duration-150;
}
.input-field-mono {
@apply w-full px-3 py-2 bg-surface-raised border border-surface-border rounded text-xs
text-text-primary placeholder-text-tertiary font-mono
focus:border-accent/50 focus:ring-1 focus:ring-accent/20 focus:outline-none
transition-colors duration-150;
}
.card {
@apply bg-surface-raised border border-surface-border rounded-lg;
}
.label {
@apply block text-xs font-medium text-text-secondary mb-1.5;
}
}

View File

@@ -1,8 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a0a0b" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<title>Price Hunter</title>
%sveltekit.head%

View File

@@ -1,22 +1,69 @@
<script>
import '../app.css';
import { page } from '$app/stores';
let { children } = $props();
function isActive(path) {
const current = $page.url.pathname;
if (path === '/') return current === '/';
return current.startsWith(path);
}
</script>
<div class="min-h-screen flex flex-col">
<nav class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<a href="/" class="text-xl font-bold text-blue-600 dark:text-blue-400 hover:text-blue-700">
Price Hunter
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-56 flex-shrink-0 bg-surface border-r border-surface-border flex flex-col">
<!-- Logo -->
<div class="px-4 py-4 border-b border-surface-border">
<a href="/" class="flex items-center gap-2.5 group">
<div class="w-6 h-6 rounded bg-accent flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
</div>
<span class="text-sm font-semibold text-text-primary group-hover:text-white transition-colors">Price Hunter</span>
</a>
<div class="flex gap-4 text-sm">
<a href="/" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Search</a>
<a href="/admin" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Stores</a>
<a href="/admin/categories" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Categories</a>
</div>
</div>
<!-- Nav -->
<nav class="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto">
<span class="block px-2 py-1.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider">Search</span>
<a href="/" class="flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors duration-100
{isActive('/') && !isActive('/results') && !isActive('/admin') ? 'bg-surface-hover text-text-primary' : 'text-text-secondary hover:text-text-primary hover:bg-surface-hover'}">
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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>
Search
</a>
<a href="/results?q=" class="flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors duration-100
{isActive('/results') ? 'bg-surface-hover text-text-primary' : 'text-text-secondary hover:text-text-primary hover:bg-surface-hover'}">
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
Results
</a>
<span class="block px-2 pt-4 pb-1.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider">Manage</span>
<a href="/admin" class="flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors duration-100
{isActive('/admin') && !isActive('/admin/categories') ? 'bg-surface-hover text-text-primary' : 'text-text-secondary hover:text-text-primary hover:bg-surface-hover'}">
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 01.75-.75h3a.75.75 0 01.75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349m-16.5 11.65V9.35m0 0a3.001 3.001 0 003.75-.615A2.993 2.993 0 009.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 002.25 1.016c.896 0 1.7-.393 2.25-1.016A3.001 3.001 0 0021 9.349m-18 0a2.997 2.997 0 00.177-.728C3.364 7.364 4 6 4.5 4.5h15c.5 1.5 1.136 2.864 1.323 3.621a2.997 2.997 0 00.177.728" />
</svg>
Stores
</a>
<a href="/admin/categories" class="flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors duration-100
{isActive('/admin/categories') ? 'bg-surface-hover text-text-primary' : 'text-text-secondary hover:text-text-primary hover:bg-surface-hover'}">
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
Categories
</a>
</nav>
<main class="flex-1">
</aside>
<!-- Main content -->
<main class="flex-1 overflow-y-auto bg-surface">
{@render children()}
</main>
</div>

View File

@@ -25,36 +25,43 @@
}
</script>
<div class="flex flex-col items-center justify-center min-h-[80vh] px-4">
<h1 class="text-5xl font-bold text-gray-900 dark:text-white mb-2">Price Hunter</h1>
<p class="text-gray-500 dark:text-gray-400 mb-8">Search across all your stores at once</p>
<div class="flex flex-col items-center justify-center min-h-full px-4">
<div class="w-full max-w-xl -mt-20">
<div class="mb-8 text-center">
<h1 class="text-3xl font-semibold text-text-primary mb-1.5">Price Hunter</h1>
<p class="text-sm text-text-tertiary">Search across all your stores at once</p>
</div>
<form onsubmit={handleSearch} class="w-full max-w-2xl">
<div class="relative">
<form onsubmit={handleSearch}>
<div class="relative group">
<svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary group-focus-within:text-accent transition-colors"
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" />
</svg>
<input
type="text"
bind:value={query}
placeholder="Search for a product..."
class="w-full px-6 py-4 text-lg border-2 border-gray-200 dark:border-gray-700 rounded-full
bg-white dark:bg-gray-900 text-gray-900 dark:text-white
focus:border-blue-500 focus:outline-none shadow-sm hover:shadow-md transition-shadow"
autofocus
class="w-full pl-10 pr-20 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 duration-150"
/>
<button
type="submit"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700 text-white
px-6 py-2.5 rounded-full font-medium transition-colors"
class="absolute right-1.5 top-1/2 -translate-y-1/2 btn-primary py-1.5 px-3.5 text-xs"
>
Search
</button>
</div>
{#if categories.length > 0 || groups.length > 0}
<div class="flex flex-wrap gap-2 mt-4 justify-center">
<div class="flex gap-2 mt-3 justify-center">
{#if categories.length > 0}
<select
bind:value={selectedCategory}
class="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
class="input-field w-auto text-xs py-1.5 px-2.5"
>
<option value="">All categories</option>
{#each categories as cat}
@@ -66,8 +73,7 @@
{#if groups.length > 0}
<select
bind:value={selectedGroup}
class="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
class="input-field w-auto text-xs py-1.5 px-2.5"
>
<option value="">All groups</option>
{#each groups as group}
@@ -79,3 +85,4 @@
{/if}
</form>
</div>
</div>

View File

@@ -42,98 +42,93 @@
}
</script>
<div class="max-w-7xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Store Management</h1>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3 flex items-center justify-between">
<h1 class="text-sm font-semibold text-text-primary">Stores</h1>
<div class="flex gap-2">
<button onclick={handleSync}
class="border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
title="Reload store configs from YAML files in the stores/ directory">
<button onclick={handleSync} class="btn-secondary text-xs py-1" title="Reload from YAML files">
Sync from Files
</button>
<button onclick={handleExport}
class="border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
title="Export all store configs to YAML files">
<button onclick={handleExport} class="btn-secondary text-xs py-1" title="Export all to YAML">
Export to Files
</button>
<a
href="/admin/stores/new"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Add Store
</a>
<a href="/admin/stores/new" class="btn-primary text-xs py-1">Add Store</a>
</div>
</div>
{#if syncMessage}
<div class="mb-4 px-4 py-2.5 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-sm">
<div class="mx-6 mt-3 px-3 py-2 rounded bg-accent-muted border border-accent/20 text-accent-text text-xs">
{syncMessage}
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto">
{#if loading}
<div class="animate-pulse space-y-3">
<div class="p-6 space-y-2">
{#each Array(5) as _}
<div class="bg-gray-200 dark:bg-gray-800 h-16 rounded-lg"></div>
<div class="bg-surface-raised h-12 rounded animate-pulse"></div>
{/each}
</div>
{:else if stores.length === 0}
<div class="text-center py-20 text-gray-500 dark:text-gray-400">
<p class="text-lg mb-2">No stores configured yet</p>
<a href="/admin/stores/new" class="text-blue-600 hover:underline">Add your first store</a>
<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="M13.5 21v-7.5a.75.75 0 01.75-.75h3a.75.75 0 01.75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64" />
</svg>
<p class="text-sm mb-1">No stores configured yet</p>
<a href="/admin/stores/new" class="text-xs text-accent-text hover:text-accent">Add your first store</a>
</div>
{:else}
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<table class="w-full">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800 text-left text-sm text-gray-600 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">URL</th>
<th class="px-4 py-3 font-medium">Category</th>
<th class="px-4 py-3 font-medium text-center">Enabled</th>
<th class="px-4 py-3 font-medium text-right">Actions</th>
<tr class="border-b border-surface-border text-left">
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider">Name</th>
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider">URL</th>
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider">Category</th>
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider text-center">Status</th>
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each stores as store}
<tr class="border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3">
<span class="font-medium text-gray-900 dark:text-white">{store.name}</span>
<tr class="border-b border-surface-border hover:bg-surface-hover/50 transition-colors duration-100">
<td class="px-6 py-2.5">
<span class="text-sm font-medium text-text-primary">{store.name}</span>
</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
{store.base_url}
</td>
<td class="px-4 py-3">
<td class="px-6 py-2.5 text-xs text-text-tertiary truncate max-w-xs">{store.base_url}</td>
<td class="px-6 py-2.5">
{#if store.category_name}
<span
class="text-xs px-2 py-0.5 rounded-full text-white"
style="background-color: {store.category_color}"
>
<span class="text-2xs px-2 py-0.5 rounded-full font-medium"
style="background-color: {store.category_color}20; color: {store.category_color}">
{store.category_name}
</span>
{:else}
<span class="text-xs text-gray-400"></span>
<span class="text-2xs text-text-tertiary"></span>
{/if}
</td>
<td class="px-4 py-3 text-center">
<button
onclick={() => handleToggle(store.id)}
class="w-10 h-5 rounded-full transition-colors {store.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'} relative"
>
<span class="absolute top-0.5 {store.enabled ? 'right-0.5' : 'left-0.5'} w-4 h-4 bg-white rounded-full shadow transition-all"></span>
<td class="px-6 py-2.5 text-center">
<button onclick={() => handleToggle(store.id)}
class="inline-flex items-center gap-1.5 text-2xs font-medium px-2 py-0.5 rounded-full transition-colors
{store.enabled
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
: 'bg-surface-hover text-text-tertiary hover:text-text-secondary'}"
aria-label="Toggle store {store.name}">
<span class="w-1.5 h-1.5 rounded-full {store.enabled ? 'bg-emerald-400' : 'bg-text-tertiary'}"></span>
{store.enabled ? 'Active' : 'Disabled'}
</button>
</td>
<td class="px-4 py-3 text-right">
<div class="flex gap-2 justify-end">
<a href="/admin/stores/{store.id}" class="text-sm text-blue-600 hover:underline">Edit</a>
<a href="/admin/stores/{store.id}/test" class="text-sm text-green-600 hover:underline">Test</a>
<button onclick={() => handleDelete(store.id, store.name)} class="text-sm text-red-600 hover:underline">Delete</button>
<td class="px-6 py-2.5 text-right">
<div class="flex gap-1 justify-end">
<a href="/admin/stores/{store.id}" class="btn-secondary text-xs py-0.5 px-2">Edit</a>
<a href="/admin/stores/{store.id}/test" class="btn-secondary text-xs py-0.5 px-2">Test</a>
<button onclick={() => handleDelete(store.id, store.name)} class="btn-danger text-xs py-0.5 px-2">Delete</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>

View File

@@ -11,10 +11,9 @@
let loading = $state(true);
let newCatName = $state('');
let newCatColor = $state('#6B7280');
let newCatColor = $state('#8b5cf6');
let newGroupName = $state('');
let editingCat = $state(null);
let editingGroup = $state(null);
onMount(async () => {
[categories, groups, stores] = await Promise.all([getCategories(), getGroups(), getStores()]);
@@ -26,7 +25,7 @@
const cat = await createCategory(newCatName.trim(), newCatColor);
categories = [...categories, cat];
newCatName = '';
newCatColor = '#6B7280';
newCatColor = '#8b5cf6';
}
async function handleUpdateCategory(id) {
@@ -58,53 +57,57 @@
async function handleToggleGroupMember(groupId, storeId) {
const group = groups.find((g) => g.id === groupId);
if (!group) return;
const ids = group.store_ids.includes(storeId)
? group.store_ids.filter((id) => id !== storeId)
: [...group.store_ids, storeId];
await setGroupMembersApi(groupId, ids);
groups = groups.map((g) => (g.id === groupId ? { ...g, store_ids: ids } : g));
}
</script>
<div class="max-w-5xl mx-auto px-4 py-6">
<div class="h-full flex flex-col">
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3">
<h1 class="text-sm font-semibold text-text-primary">Categories & Groups</h1>
</div>
<div class="flex-1 overflow-y-auto p-6">
{#if loading}
<div class="animate-pulse space-y-4">
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
<div class="space-y-4 animate-pulse">
<div class="bg-surface-raised h-40 rounded-lg"></div>
<div class="bg-surface-raised h-40 rounded-lg"></div>
</div>
{:else}
<div class="max-w-2xl space-y-8">
<!-- Categories -->
<section class="mb-10">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Categories</h1>
<section>
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-3">Categories</h2>
<div class="flex gap-2 mb-4">
<input type="text" bind:value={newCatName} placeholder="Category name"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<div class="flex gap-2 mb-3">
<input type="text" bind:value={newCatName} placeholder="Category name" class="input-field flex-1" />
<input type="color" bind:value={newCatColor}
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer" />
<button onclick={handleAddCategory}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Add</button>
class="w-9 h-9 rounded border border-surface-border cursor-pointer bg-surface-raised" />
<button onclick={handleAddCategory} class="btn-primary text-xs">Add</button>
</div>
{#if categories.length === 0}
<p class="text-gray-500 dark:text-gray-400">No categories yet.</p>
<p class="text-xs text-text-tertiary py-4">No categories yet.</p>
{:else}
<div class="space-y-2">
<div class="space-y-1">
{#each categories as cat}
<div class="flex items-center gap-3 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3">
<div class="card flex items-center gap-3 px-4 py-2.5">
{#if editingCat?.id === cat.id}
<input type="color" bind:value={editingCat.color} class="w-8 h-8 rounded cursor-pointer" />
<input type="text" bind:value={editingCat.name}
class="flex-1 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
<button onclick={() => handleUpdateCategory(cat.id)} class="text-sm text-blue-600 hover:underline">Save</button>
<button onclick={() => editingCat = null} class="text-sm text-gray-500 hover:underline">Cancel</button>
<input type="color" bind:value={editingCat.color}
class="w-6 h-6 rounded cursor-pointer bg-transparent border-0" />
<input type="text" bind:value={editingCat.name} class="input-field flex-1 py-1" />
<button onclick={() => handleUpdateCategory(cat.id)} class="text-2xs text-accent-text hover:text-accent">Save</button>
<button onclick={() => editingCat = null} class="text-2xs text-text-tertiary hover:text-text-secondary">Cancel</button>
{:else}
<span class="w-4 h-4 rounded-full" style="background-color: {cat.color}"></span>
<span class="flex-1 text-gray-900 dark:text-white">{cat.name}</span>
<button onclick={() => editingCat = { id: cat.id, name: cat.name, color: cat.color }} class="text-sm text-blue-600 hover:underline">Edit</button>
<button onclick={() => handleDeleteCategory(cat.id, cat.name)} class="text-sm text-red-600 hover:underline">Delete</button>
<span class="w-3 h-3 rounded-sm flex-shrink-0" style="background-color: {cat.color}"></span>
<span class="flex-1 text-sm text-text-primary">{cat.name}</span>
<button onclick={() => editingCat = { id: cat.id, name: cat.name, color: cat.color }}
class="text-2xs text-text-tertiary hover:text-text-secondary">Edit</button>
<button onclick={() => handleDeleteCategory(cat.id, cat.name)}
class="text-2xs text-text-tertiary hover:text-red-400">Delete</button>
{/if}
</div>
{/each}
@@ -114,33 +117,32 @@
<!-- Groups -->
<section>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Custom Groups</h1>
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-3">Custom Groups</h2>
<div class="flex gap-2 mb-4">
<input type="text" bind:value={newGroupName} placeholder="Group name"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<button onclick={handleAddGroup}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Add</button>
<div class="flex gap-2 mb-3">
<input type="text" bind:value={newGroupName} placeholder="Group name" class="input-field flex-1" />
<button onclick={handleAddGroup} class="btn-primary text-xs">Add</button>
</div>
{#if groups.length === 0}
<p class="text-gray-500 dark:text-gray-400">No groups yet.</p>
<p class="text-xs text-text-tertiary py-4">No groups yet.</p>
{:else}
<div class="space-y-4">
<div class="space-y-3">
{#each groups as group}
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-gray-900 dark:text-white">{group.name}</h3>
<button onclick={() => handleDeleteGroup(group.id, group.name)} class="text-sm text-red-600 hover:underline">Delete</button>
<h3 class="text-sm font-medium text-text-primary">{group.name}</h3>
<button onclick={() => handleDeleteGroup(group.id, group.name)}
class="text-2xs text-text-tertiary hover:text-red-400">Delete</button>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-1.5">
{#each stores as store}
<button
onclick={() => handleToggleGroupMember(group.id, store.id)}
class="text-xs px-3 py-1 rounded-full border transition-colors
class="text-2xs px-2.5 py-1 rounded border transition-colors duration-100
{group.store_ids.includes(store.id)
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-gray-300'}"
? 'bg-accent-muted border-accent/30 text-accent-text'
: 'bg-surface border-surface-border text-text-tertiary hover:text-text-secondary hover:border-surface-border-hover'}"
>
{store.name}
</button>
@@ -151,5 +153,7 @@
</div>
{/if}
</section>
</div>
{/if}
</div>
</div>

View File

@@ -10,42 +10,22 @@
let loading = $state(true);
let form = $state({
name: '',
base_url: '',
search_url: '',
sel_container: '',
sel_name: '',
sel_price: '',
sel_link: '',
sel_image: '',
category_id: '',
currency: 'EUR',
rate_limit: 2,
user_agent: '',
proxy_url: '',
headers_json: '',
name: '', base_url: '', search_url: '',
sel_container: '', sel_name: '', sel_price: '', sel_link: '', sel_image: '',
category_id: '', currency: 'EUR', rate_limit: 2,
user_agent: '', proxy_url: '', headers_json: '',
});
onMount(async () => {
const id = $page.params.id;
const [store, cats] = await Promise.all([getStore(Number(id)), getCategories()]);
const [store, cats] = await Promise.all([getStore(Number($page.params.id)), getCategories()]);
categories = cats;
form = {
name: store.name,
base_url: store.base_url,
search_url: store.search_url,
sel_container: store.sel_container,
sel_name: store.sel_name,
sel_price: store.sel_price,
sel_link: store.sel_link,
sel_image: store.sel_image || '',
category_id: store.category_id?.toString() || '',
currency: store.currency,
rate_limit: store.rate_limit,
user_agent: store.user_agent || '',
proxy_url: store.proxy_url || '',
headers_json: store.headers_json || '',
name: store.name, base_url: store.base_url, search_url: store.search_url,
sel_container: store.sel_container, sel_name: store.sel_name, sel_price: store.sel_price,
sel_link: store.sel_link, sel_image: store.sel_image || '',
category_id: store.category_id?.toString() || '', currency: store.currency,
rate_limit: store.rate_limit, user_agent: store.user_agent || '',
proxy_url: store.proxy_url || '', headers_json: store.headers_json || '',
};
loading = false;
});
@@ -54,147 +34,100 @@
e.preventDefault();
error = '';
saving = true;
try {
const data = { ...form };
if (data.category_id) data.category_id = Number(data.category_id);
else delete data.category_id;
if (data.category_id) data.category_id = Number(data.category_id); else delete data.category_id;
await updateStore(Number($page.params.id), data);
goto('/admin');
} catch (err) {
error = err.message || 'Failed to update store';
} finally {
saving = false;
}
} catch (err) { error = err.message || 'Failed to update store'; }
finally { saving = false; }
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Edit Store</h1>
<a href="/admin/stores/{$page.params.id}/test"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
Test Store
</a>
<div class="h-full flex flex-col">
<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-3">
<a href="/admin" class="text-text-tertiary hover:text-text-secondary transition-colors">&larr;</a>
<h1 class="text-sm font-semibold text-text-primary">Edit Store</h1>
</div>
<a href="/admin/stores/{$page.params.id}/test" class="btn-secondary text-xs py-1">Test Store</a>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-2xl">
{#if loading}
<div class="animate-pulse space-y-4">
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-800 h-60 rounded-lg"></div>
<div class="space-y-4 animate-pulse">
<div class="bg-surface-raised h-48 rounded-lg"></div>
<div class="bg-surface-raised h-48 rounded-lg"></div>
</div>
{:else}
{#if error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg mb-4">{error}</div>
<div class="mb-4 px-3 py-2 rounded bg-red-500/10 border border-red-500/20 text-red-400 text-xs">{error}</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6">
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<section class="card p-5">
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">Basic Information</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Store Name *</label>
<input type="text" bind:value={form.name} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">Store Name</label>
<input type="text" bind:value={form.name} required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<select bind:value={form.category_id}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
<label class="label">Category</label>
<select bind:value={form.category_id} class="input-field">
<option value="">No category</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
{#each categories as cat}<option value={cat.id}>{cat.name}</option>{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base URL *</label>
<input type="url" bind:value={form.base_url} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">Base URL</label>
<input type="url" bind:value={form.base_url} required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search URL *</label>
<input type="text" bind:value={form.search_url} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<p class="text-xs text-gray-500 mt-1">Use {'{query}'} as placeholder</p>
<label class="label">Search URL</label>
<input type="text" bind:value={form.search_url} required class="input-field" />
<p class="text-2xs text-text-tertiary mt-1">Use {'{query}'} as placeholder</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
<input type="text" bind:value={form.currency}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">Currency</label>
<input type="text" bind:value={form.currency} class="input-field" />
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">CSS Selectors</h2>
<div class="space-y-4">
<section class="card p-5">
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">CSS Selectors</h2>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Container *</label>
<input type="text" bind:value={form.sel_container} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Name *</label>
<input type="text" bind:value={form.sel_name} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Price *</label>
<input type="text" bind:value={form.sel_price} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Link *</label>
<input type="text" bind:value={form.sel_link} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image</label>
<input type="text" bind:value={form.sel_image}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
<label class="label">Product Container</label>
<input type="text" bind:value={form.sel_container} required class="input-field-mono" />
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="label">Product Name</label><input type="text" bind:value={form.sel_name} required class="input-field-mono" /></div>
<div><label class="label">Price</label><input type="text" bind:value={form.sel_price} required class="input-field-mono" /></div>
<div><label class="label">Product Link</label><input type="text" bind:value={form.sel_link} required class="input-field-mono" /></div>
<div><label class="label">Image</label><input type="text" bind:value={form.sel_image} class="input-field-mono" /></div>
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Advanced Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rate Limit</label>
<input type="number" bind:value={form.rate_limit} min="1" max="10"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Agent</label>
<input type="text" bind:value={form.user_agent}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Proxy URL</label>
<input type="text" bind:value={form.proxy_url}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Extra Headers (JSON)</label>
<input type="text" bind:value={form.headers_json}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<section class="card p-5">
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">Advanced</h2>
<div class="grid grid-cols-2 gap-4">
<div><label class="label">Rate Limit</label><input type="number" bind:value={form.rate_limit} min="1" max="10" class="input-field" /></div>
<div><label class="label">User Agent</label><input type="text" bind:value={form.user_agent} class="input-field" /></div>
<div><label class="label">Proxy URL</label><input type="text" bind:value={form.proxy_url} class="input-field" /></div>
<div><label class="label">Extra Headers (JSON)</label><input type="text" bind:value={form.headers_json} class="input-field-mono" /></div>
</div>
</section>
<div class="flex gap-3">
<button type="submit" disabled={saving}
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2.5 rounded-lg font-medium">
{saving ? 'Saving...' : 'Save Changes'}
</button>
<a href="/admin" class="px-6 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">
Cancel
</a>
<div class="flex gap-2">
<button type="submit" disabled={saving} class="btn-primary {saving ? 'opacity-50' : ''}">{saving ? 'Saving...' : 'Save Changes'}</button>
<a href="/admin" class="btn-secondary">Cancel</a>
</div>
</form>
{/if}
</div>
</div>
</div>

View File

@@ -19,127 +19,113 @@
if (!query.trim()) return;
testing = true;
result = null;
try {
result = await testStore(Number($page.params.id), query.trim());
} catch (err) {
result = { success: false, error: err.message };
} finally {
testing = false;
}
try { result = await testStore(Number($page.params.id), query.trim()); }
catch (err) { result = { success: false, error: err.message }; }
finally { testing = false; }
}
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)}`;
}
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price); }
catch { return `${currency} ${price.toFixed(2)}`; }
}
</script>
<div class="max-w-7xl mx-auto px-4 py-6">
<div class="h-full flex flex-col">
{#if loading}
<div class="animate-pulse">
<div class="bg-gray-200 dark:bg-gray-800 h-8 w-48 rounded mb-4"></div>
<div class="bg-gray-200 dark:bg-gray-800 h-12 rounded mb-4"></div>
<div class="p-6 animate-pulse">
<div class="bg-surface-raised h-8 w-48 rounded mb-4"></div>
<div class="bg-surface-raised h-10 rounded"></div>
</div>
{:else if store}
<div class="flex items-center gap-4 mb-6">
<a href="/admin/stores/{store.id}" class="text-gray-400 hover:text-gray-600">&larr;</a>
<!-- 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-3">
<a href="/admin/stores/{store.id}" class="text-text-tertiary hover:text-text-secondary transition-colors">&larr;</a>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Test: {store.name}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">{store.base_url}</p>
<h1 class="text-sm font-semibold text-text-primary">Test: {store.name}</h1>
<p class="text-2xs text-text-tertiary">{store.base_url}</p>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-6 space-y-5">
<!-- Health Stats -->
{#if store.health}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="text-2xl font-bold text-gray-900 dark:text-white">{store.health.total}</div>
<div class="text-xs text-gray-500">Total Scrapes</div>
<div class="grid grid-cols-4 gap-3">
<div class="card px-4 py-3">
<div class="text-lg font-semibold text-text-primary">{store.health.total}</div>
<div class="text-2xs text-text-tertiary">Total Scrapes</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="text-2xl font-bold text-green-600">{store.health.successful}</div>
<div class="text-xs text-gray-500">Successful</div>
<div class="card px-4 py-3">
<div class="text-lg font-semibold text-emerald-400">{store.health.successful}</div>
<div class="text-2xs text-text-tertiary">Successful</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="text-2xl font-bold text-red-600">{store.health.failed}</div>
<div class="text-xs text-gray-500">Failed</div>
<div class="card px-4 py-3">
<div class="text-lg font-semibold text-red-400">{store.health.failed}</div>
<div class="text-2xs text-text-tertiary">Failed</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="text-2xl font-bold text-gray-900 dark:text-white">{store.health.avg_duration_ms}ms</div>
<div class="text-xs text-gray-500">Avg Duration</div>
<div class="card px-4 py-3">
<div class="text-lg font-semibold text-text-primary">{store.health.avg_duration_ms}ms</div>
<div class="text-2xs text-text-tertiary">Avg Duration</div>
</div>
</div>
{/if}
<!-- Test Form -->
<form onsubmit={handleTest} class="mb-6">
<div class="flex gap-2">
<input
type="text"
bind:value={query}
placeholder="Enter a test search query..."
class="flex-1 px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={testing}
class="bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white px-6 py-2.5 rounded-lg font-medium"
>
<form onsubmit={handleTest} class="flex gap-2">
<input type="text" bind:value={query} placeholder="Enter a test search query..."
class="input-field flex-1" />
<button type="submit" disabled={testing} class="btn-primary {testing ? 'opacity-50' : ''}">
{testing ? 'Testing...' : 'Run Test'}
</button>
</div>
</form>
<!-- Test Results -->
<!-- Results -->
{#if result}
<div class="space-y-6">
<!-- Status Banner -->
<div class="p-4 rounded-lg {result.success ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' : 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400'}">
<div class="font-medium">
<!-- Status -->
<div class="card px-4 py-3 {result.success ? 'border-emerald-500/20' : 'border-red-500/20'}">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 rounded-full {result.success ? 'bg-emerald-400' : 'bg-red-400'}"></span>
<span class="text-sm font-medium {result.success ? 'text-emerald-400' : 'text-red-400'}">
{result.success ? `Found ${result.itemsFound} products` : 'Test failed'}
</span>
</div>
{#if result.searchUrl}
<div class="text-sm mt-1 break-all opacity-80">URL: {result.searchUrl}</div>
<p class="text-2xs text-text-tertiary break-all mt-1">{result.searchUrl}</p>
{/if}
{#if result.duration}
<div class="text-sm mt-1 opacity-80">Duration: {result.duration}ms</div>
<p class="text-2xs text-text-tertiary mt-0.5">{result.duration}ms</p>
{/if}
{#if result.error}
<div class="text-sm mt-1">{result.error}</div>
<p class="text-xs text-red-400 mt-1">{result.error}</p>
{/if}
</div>
{#if result.success}
<!-- Parsed Results -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Parsed Products ({result.parsedProducts?.length || 0})</h2>
<!-- Parsed Products Table -->
{#if result.parsedProducts?.length > 0}
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<table class="w-full text-sm">
<div class="card overflow-hidden">
<div class="px-4 py-2.5 border-b border-surface-border">
<h2 class="text-xs font-semibold text-text-primary">Parsed Products ({result.parsedProducts.length})</h2>
</div>
<table class="w-full text-xs">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800 text-left">
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Name</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Price</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Raw Price</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Link</th>
<tr class="border-b border-surface-border">
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Name</th>
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Price</th>
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Raw</th>
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Link</th>
</tr>
</thead>
<tbody>
{#each result.parsedProducts as product}
<tr class="border-t border-gray-100 dark:border-gray-800">
<td class="px-4 py-2 text-gray-900 dark:text-white max-w-xs truncate">{product.name}</td>
<td class="px-4 py-2 font-medium text-blue-600 dark:text-blue-400">{formatPrice(product.price, product.currency)}</td>
<td class="px-4 py-2 text-gray-500 font-mono text-xs">{product.priceText}</td>
<tr class="border-b border-surface-border hover:bg-surface-hover/50 transition-colors">
<td class="px-4 py-2 text-text-primary max-w-xs truncate">{product.name}</td>
<td class="px-4 py-2 font-medium text-accent-text">{formatPrice(product.price, product.currency)}</td>
<td class="px-4 py-2 text-text-tertiary font-mono">{product.priceText}</td>
<td class="px-4 py-2">
<a href={product.url} target="_blank" rel="noopener" class="text-blue-600 hover:underline truncate block max-w-xs">
{product.url}
</a>
<a href={product.url} target="_blank" rel="noopener" class="text-accent-text hover:text-accent truncate block max-w-xs">{product.url}</a>
</td>
</tr>
{/each}
@@ -147,54 +133,53 @@
</table>
</div>
{:else}
<p class="text-gray-500 dark:text-gray-400">No products parsed. Check your CSS selectors.</p>
{/if}
<div class="card px-4 py-6 text-center text-text-tertiary text-sm">
No products parsed. Check your CSS selectors.
</div>
{/if}
<!-- Raw HTML Preview -->
<details class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
<summary class="px-4 py-3 cursor-pointer font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800">
<!-- Raw HTML -->
<details class="card">
<summary class="px-4 py-2.5 cursor-pointer text-xs font-medium text-text-secondary hover:text-text-primary transition-colors">
Raw HTML Preview ({result.rawHtmlLength?.toLocaleString()} bytes)
</summary>
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-800">
<pre class="text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap max-h-96 overflow-y-auto">{result.rawHtmlPreview}</pre>
<div class="px-4 py-3 border-t border-surface-border">
<pre class="text-2xs text-text-tertiary overflow-x-auto whitespace-pre-wrap max-h-72 overflow-y-auto font-mono">{result.rawHtmlPreview}</pre>
</div>
</details>
{/if}
<!-- Recent Logs -->
{#if result.recentLogs?.length > 0}
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Recent Scrape Logs</h2>
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<table class="w-full text-sm">
<div class="card overflow-hidden">
<div class="px-4 py-2.5 border-b border-surface-border">
<h2 class="text-xs font-semibold text-text-primary">Recent Scrape Logs</h2>
</div>
<table class="w-full text-xs">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800 text-left">
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Status</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Query</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Results</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Duration</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Time</th>
<tr class="border-b border-surface-border">
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider w-8"></th>
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Query</th>
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Results</th>
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Duration</th>
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Time</th>
</tr>
</thead>
<tbody>
{#each result.recentLogs as log}
<tr class="border-t border-gray-100 dark:border-gray-800">
<td class="px-4 py-2">
<span class="inline-block w-2 h-2 rounded-full {log.success ? 'bg-green-500' : 'bg-red-500'}"></span>
</td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{log.query}</td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{log.result_count}</td>
<td class="px-4 py-2 text-gray-500">{log.duration_ms}ms</td>
<td class="px-4 py-2 text-gray-500 text-xs">{log.scraped_at}</td>
<tr class="border-b border-surface-border">
<td class="px-4 py-2"><span class="w-1.5 h-1.5 rounded-full inline-block {log.success ? 'bg-emerald-400' : 'bg-red-400'}"></span></td>
<td class="px-4 py-2 text-text-secondary">{log.query}</td>
<td class="px-4 py-2 text-text-secondary">{log.result_count}</td>
<td class="px-4 py-2 text-text-tertiary">{log.duration_ms}ms</td>
<td class="px-4 py-2 text-text-tertiary">{log.scraped_at}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
{/if}
</div>
{/if}
</div>

View File

@@ -8,167 +8,136 @@
let saving = $state(false);
let form = $state({
name: '',
base_url: '',
search_url: '',
sel_container: '',
sel_name: '',
sel_price: '',
sel_link: '',
sel_image: '',
category_id: '',
currency: 'EUR',
rate_limit: 2,
user_agent: '',
proxy_url: '',
headers_json: '',
name: '', base_url: '', search_url: '',
sel_container: '', sel_name: '', sel_price: '', sel_link: '', sel_image: '',
category_id: '', currency: 'EUR', rate_limit: 2,
user_agent: '', proxy_url: '', headers_json: '',
});
onMount(async () => {
categories = await getCategories();
});
onMount(async () => { categories = await getCategories(); });
async function handleSubmit(e) {
e.preventDefault();
error = '';
saving = true;
try {
const data = { ...form };
if (data.category_id) data.category_id = Number(data.category_id);
else delete data.category_id;
if (data.category_id) data.category_id = Number(data.category_id); else delete data.category_id;
if (!data.sel_image) delete data.sel_image;
if (!data.user_agent) delete data.user_agent;
if (!data.proxy_url) delete data.proxy_url;
if (!data.headers_json) delete data.headers_json;
const store = await createStore(data);
goto(`/admin/stores/${store.id}/test`);
} catch (err) {
error = err.message || 'Failed to create store';
} finally {
saving = false;
}
} catch (err) { error = err.message || 'Failed to create store'; }
finally { saving = false; }
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Add New Store</h1>
<div class="h-full flex flex-col">
<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-3">
<a href="/admin" class="text-text-tertiary hover:text-text-secondary transition-colors">&larr;</a>
<h1 class="text-sm font-semibold text-text-primary">Add New Store</h1>
</div>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-2xl">
{#if error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg mb-4">{error}</div>
<div class="mb-4 px-3 py-2 rounded bg-red-500/10 border border-red-500/20 text-red-400 text-xs">{error}</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6">
<!-- Basic Info -->
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<section class="card p-5">
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">Basic Information</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Store Name *</label>
<input type="text" bind:value={form.name} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">Store Name</label>
<input type="text" bind:value={form.name} required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<select bind:value={form.category_id}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
<label class="label">Category</label>
<select bind:value={form.category_id} class="input-field">
<option value="">No category</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
{#each categories as cat}<option value={cat.id}>{cat.name}</option>{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base URL *</label>
<input type="url" bind:value={form.base_url} required placeholder="https://store.example.com"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">Base URL</label>
<input type="url" bind:value={form.base_url} required placeholder="https://store.example.com" class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search URL *</label>
<input type="text" bind:value={form.search_url} required placeholder={'https://store.example.com/search?q={query}'}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<p class="text-xs text-gray-500 mt-1">Use {'{query}'} as placeholder for the search term</p>
<label class="label">Search URL</label>
<input type="text" bind:value={form.search_url} required placeholder={'https://store.example.com/search?q={query}'} class="input-field" />
<p class="text-2xs text-text-tertiary mt-1">Use {'{query}'} as placeholder for the search term</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
<input type="text" bind:value={form.currency} placeholder="EUR"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">Currency</label>
<input type="text" bind:value={form.currency} placeholder="EUR" class="input-field" />
</div>
</div>
</section>
<!-- CSS Selectors -->
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">CSS Selectors</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Define how to extract product data from the store's search results page.</p>
<div class="space-y-4">
<section class="card p-5">
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-1">CSS Selectors</h2>
<p class="text-2xs text-text-tertiary mb-4">Define how to extract product data from the store's search results page.</p>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Container *</label>
<input type="text" bind:value={form.sel_container} required placeholder=".product-card"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
<p class="text-xs text-gray-500 mt-1">Selector for each product listing item</p>
<label class="label">Product Container</label>
<input type="text" bind:value={form.sel_container} required placeholder=".product-card" class="input-field-mono" />
<p class="text-2xs text-text-tertiary mt-1">Selector for each product listing item</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Name *</label>
<input type="text" bind:value={form.sel_name} required placeholder=".product-title"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
<label class="label">Product Name</label>
<input type="text" bind:value={form.sel_name} required placeholder=".product-title" class="input-field-mono" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Price *</label>
<input type="text" bind:value={form.sel_price} required placeholder=".product-price"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
<label class="label">Price</label>
<input type="text" bind:value={form.sel_price} required placeholder=".product-price" class="input-field-mono" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Link *</label>
<input type="text" bind:value={form.sel_link} required placeholder="a"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
<label class="label">Product Link</label>
<input type="text" bind:value={form.sel_link} required placeholder="a" class="input-field-mono" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image (optional)</label>
<input type="text" bind:value={form.sel_image} placeholder="img"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
<label class="label">Image (optional)</label>
<input type="text" bind:value={form.sel_image} placeholder="img" class="input-field-mono" />
</div>
</div>
</div>
</section>
<!-- Advanced -->
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Advanced Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<section class="card p-5">
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">Advanced</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rate Limit (req/sec)</label>
<input type="number" bind:value={form.rate_limit} min="1" max="10"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">Rate Limit (req/sec)</label>
<input type="number" bind:value={form.rate_limit} min="1" max="10" class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Agent</label>
<input type="text" bind:value={form.user_agent} placeholder="Default browser UA"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">User Agent</label>
<input type="text" bind:value={form.user_agent} placeholder="Default browser UA" class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Proxy URL</label>
<input type="text" bind:value={form.proxy_url} placeholder="http://user:pass@host:port"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<label class="label">Proxy URL</label>
<input type="text" bind:value={form.proxy_url} placeholder="http://user:pass@host:port" class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Extra Headers (JSON)</label>
<input type="text" bind:value={form.headers_json} placeholder={'{"X-Custom": "value"}'}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
<label class="label">Extra Headers (JSON)</label>
<input type="text" bind:value={form.headers_json} placeholder={'{"X-Custom": "value"}'} class="input-field-mono" />
</div>
</div>
</section>
<div class="flex gap-3">
<button type="submit" disabled={saving}
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2.5 rounded-lg font-medium transition-colors">
<div class="flex gap-2">
<button type="submit" disabled={saving} class="btn-primary {saving ? 'opacity-50' : ''}">
{saving ? 'Creating...' : 'Create Store'}
</button>
<a href="/admin" class="px-6 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">
Cancel
</a>
<a href="/admin" class="btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>

View File

@@ -13,19 +13,13 @@
onMount(async () => {
const params = $page.url.searchParams;
query = params.get('q') || '';
if (!query) {
goto('/');
return;
}
if (!query) { goto('/'); return; }
await doSearch();
});
async function doSearch() {
if (!query.trim()) return;
loading = true;
try {
const params = $page.url.searchParams;
const data = await searchProducts(query, {
@@ -53,130 +47,105 @@
let sortedResults = $derived(() => {
const sorted = [...results];
switch (sortBy) {
case 'price':
sorted.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity));
break;
case 'price-desc':
sorted.sort((a, b) => (b.price ?? -Infinity) - (a.price ?? -Infinity));
break;
case 'name':
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'store':
sorted.sort((a, b) => a.storeName.localeCompare(b.storeName));
break;
case 'price': sorted.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity)); break;
case 'price-desc': sorted.sort((a, b) => (b.price ?? -Infinity) - (a.price ?? -Infinity)); break;
case 'name': sorted.sort((a, b) => a.name.localeCompare(b.name)); break;
case 'store': sorted.sort((a, b) => a.storeName.localeCompare(b.storeName)); break;
}
return sorted;
});
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)}`;
}
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price); }
catch { return `${currency} ${price.toFixed(2)}`; }
}
</script>
<div class="max-w-7xl mx-auto px-4 py-6">
<!-- Search bar -->
<form onsubmit={handleSearch} class="mb-6">
<div class="relative max-w-2xl">
<input
type="text"
bind:value={query}
placeholder="Search for a product..."
class="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full
bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
/>
<button type="submit" class="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700
text-white px-4 py-1.5 rounded-full text-sm">Search</button>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3 flex items-center gap-4">
<form onsubmit={handleSearch} class="flex-1 max-w-lg">
<div class="relative group">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary"
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" />
</svg>
<input type="text" bind:value={query} placeholder="Search..."
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" />
</div>
</form>
<!-- Meta info & sort -->
<div class="flex items-center gap-3">
{#if meta}
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
<span class="text-2xs text-text-tertiary">
{meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms
</p>
<select
bind:value={sortBy}
class="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
>
</span>
{/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>
{/if}
</div>
<!-- Errors -->
{#if meta?.errors?.length > 0}
<div class="mb-4 space-y-1">
<div class="px-6 pt-3 space-y-1">
{#each meta.errors as err}
<div class="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-1.5 rounded">
<div class="text-xs text-red-400 bg-red-500/10 border border-red-500/20 px-3 py-1.5 rounded">
{err.storeName}: {err.error}
</div>
{/each}
</div>
{/if}
<!-- Loading -->
<!-- Content -->
<div class="flex-1 overflow-y-auto p-6">
{#if loading}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{#each Array(8) as _}
<div class="animate-pulse bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded mb-3"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-4 rounded w-3/4 mb-2"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-6 rounded w-1/3"></div>
<div class="card p-4 animate-pulse">
<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}
</div>
{:else if sortedResults().length === 0}
<div class="text-center py-20 text-gray-500 dark:text-gray-400">
<p class="text-lg">No results found for "{query}"</p>
<p class="text-sm mt-2">Try a different search term or check your store configurations.</p>
<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="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<p class="text-sm">No results found for "{query}"</p>
<p class="text-xs mt-1">Try a different search term or check your store configurations.</p>
</div>
{:else}
<!-- Results grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{#each sortedResults() as product}
<a
href={product.url}
target="_blank"
rel="noopener noreferrer"
class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800
hover:shadow-lg transition-shadow overflow-hidden group"
>
<a href={product.url} target="_blank" rel="noopener noreferrer"
class="card overflow-hidden group hover:border-surface-border-hover transition-colors duration-150">
{#if product.image}
<div class="h-40 bg-gray-100 dark:bg-gray-800 flex items-center justify-center overflow-hidden">
<img
src={product.image}
alt={product.name}
class="max-h-full max-w-full object-contain p-2 group-hover:scale-105 transition-transform"
/>
<div class="h-32 bg-surface flex items-center justify-center overflow-hidden">
<img src={product.image} alt={product.name}
class="max-h-full max-w-full object-contain p-3 group-hover:scale-105 transition-transform duration-200" />
</div>
{:else}
<div class="h-40 bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<span class="text-gray-400 text-4xl">?</span>
<div class="h-32 bg-surface flex items-center justify-center">
<svg class="w-8 h-8 text-text-tertiary/30" 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}
<div class="p-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white line-clamp-2 mb-2">
{product.name}
</h3>
<div class="p-3">
<h3 class="text-xs font-medium text-text-primary line-clamp-2 mb-2 leading-relaxed">{product.name}</h3>
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-blue-600 dark:text-blue-400">
{formatPrice(product.price, product.currency)}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
{product.storeName}
</span>
<span class="text-sm font-semibold text-accent-text">{formatPrice(product.price, product.currency)}</span>
<span class="text-2xs text-text-tertiary bg-surface px-1.5 py-0.5 rounded">{product.storeName}</span>
</div>
</div>
</a>
@@ -184,3 +153,4 @@
</div>
{/if}
</div>
</div>

View File

@@ -1,9 +1,40 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'media',
darkMode: 'class',
theme: {
extend: {},
extend: {
colors: {
surface: {
DEFAULT: '#0a0a0b',
raised: '#111113',
overlay: '#18181b',
hover: '#1c1c1f',
border: 'rgba(255, 255, 255, 0.08)',
'border-hover': 'rgba(255, 255, 255, 0.12)',
},
accent: {
DEFAULT: '#8b5cf6',
hover: '#7c3aed',
muted: 'rgba(139, 92, 246, 0.15)',
text: '#a78bfa',
},
text: {
primary: '#ededef',
secondary: '#8b8b8e',
tertiary: '#5c5c5f',
},
},
fontFamily: {
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
},
fontSize: {
'2xs': '0.6875rem',
},
borderRadius: {
DEFAULT: '6px',
},
},
},
plugins: [],
};