diff --git a/src/client/src/app.css b/src/client/src/app.css index 2bcef0d..4bd379b 100644 --- a/src/client/src/app.css +++ b/src/client/src/app.css @@ -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'; + } + + ::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; + } } -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; +@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; + } } diff --git a/src/client/src/app.html b/src/client/src/app.html index 63971d2..56197ef 100644 --- a/src/client/src/app.html +++ b/src/client/src/app.html @@ -1,8 +1,9 @@ - + + Price Hunter %sveltekit.head% diff --git a/src/client/src/routes/+layout.svelte b/src/client/src/routes/+layout.svelte index 83c6f9e..d888bd3 100644 --- a/src/client/src/routes/+layout.svelte +++ b/src/client/src/routes/+layout.svelte @@ -1,22 +1,69 @@ -
- -
+ + + + + + +
{@render children()}
diff --git a/src/client/src/routes/+page.svelte b/src/client/src/routes/+page.svelte index 329c929..2bca96f 100644 --- a/src/client/src/routes/+page.svelte +++ b/src/client/src/routes/+page.svelte @@ -25,57 +25,64 @@ } -
-

Price Hunter

-

Search across all your stores at once

- -
-
- - +
+
+
+

Price Hunter

+

Search across all your stores at once

- {#if categories.length > 0 || groups.length > 0} -
- {#if categories.length > 0} - - {/if} - - {#if groups.length > 0} - - {/if} + +
+ + + + +
- {/if} - + + {#if categories.length > 0 || groups.length > 0} +
+ {#if categories.length > 0} + + {/if} + + {#if groups.length > 0} + + {/if} +
+ {/if} + +
diff --git a/src/client/src/routes/admin/+page.svelte b/src/client/src/routes/admin/+page.svelte index 5debb78..2b14f14 100644 --- a/src/client/src/routes/admin/+page.svelte +++ b/src/client/src/routes/admin/+page.svelte @@ -42,98 +42,93 @@ } -
-
-

Store Management

+
+ +
+

Stores

- - - - Add Store - + Add Store
{#if syncMessage} -
+
{syncMessage}
{/if} - {#if loading} -
- {#each Array(5) as _} -
- {/each} -
- {:else if stores.length === 0} -
-

No stores configured yet

- Add your first store -
- {:else} -
+ +
+ {#if loading} +
+ {#each Array(5) as _} +
+ {/each} +
+ {:else if stores.length === 0} +
+ + + +

No stores configured yet

+ Add your first store +
+ {:else} - - - - - - + + + + + + {#each stores as store} - - + - - + - - {/each}
NameURLCategoryEnabledActions
NameURLCategoryStatusActions
- {store.name} +
+ {store.name} - {store.base_url} - + {store.base_url} {#if store.category_name} - + {store.category_name} {:else} - + {/if} - + -
- Edit - Test - +
+
+ Edit + Test +
-
- {/if} + {/if} +
diff --git a/src/client/src/routes/admin/categories/+page.svelte b/src/client/src/routes/admin/categories/+page.svelte index f190d54..04656ab 100644 --- a/src/client/src/routes/admin/categories/+page.svelte +++ b/src/client/src/routes/admin/categories/+page.svelte @@ -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,98 +57,103 @@ 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)); } -
- {#if loading} -
-
-
-
- {:else} - -
-

Categories

+
+
+

Categories & Groups

+
-
- - - +
+ {#if loading} +
+
+
+ {:else} +
+ +
+

Categories

- {#if categories.length === 0} -

No categories yet.

- {:else} -
- {#each categories as cat} -
- {#if editingCat?.id === cat.id} - - - - - {:else} - - {cat.name} - - - {/if} +
+ + + +
+ + {#if categories.length === 0} +

No categories yet.

+ {:else} +
+ {#each categories as cat} +
+ {#if editingCat?.id === cat.id} + + + + + {:else} + + {cat.name} + + + {/if} +
+ {/each}
- {/each} -
- {/if} -
+ {/if} +
- -
-

Custom Groups

+ +
+

Custom Groups

-
- - +
+ + +
+ + {#if groups.length === 0} +

No groups yet.

+ {:else} +
+ {#each groups as group} +
+
+

{group.name}

+ +
+
+ {#each stores as store} + + {/each} +
+
+ {/each} +
+ {/if} +
- - {#if groups.length === 0} -

No groups yet.

- {:else} -
- {#each groups as group} -
-
-

{group.name}

- -
-
- {#each stores as store} - - {/each} -
-
- {/each} -
- {/if} - - {/if} + {/if} +
diff --git a/src/client/src/routes/admin/stores/[id]/+page.svelte b/src/client/src/routes/admin/stores/[id]/+page.svelte index 4bfce34..c5a7f44 100644 --- a/src/client/src/routes/admin/stores/[id]/+page.svelte +++ b/src/client/src/routes/admin/stores/[id]/+page.svelte @@ -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; } } -
-
-

Edit Store

- - Test Store - +
+
+
+ +

Edit Store

+
+ Test Store
- {#if loading} -
-
-
+
+
+ {#if loading} +
+
+
+
+ {:else} + {#if error} +
{error}
+ {/if} + +
+
+

Basic Information

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +

Use {'{query}'} as placeholder

+
+
+ + +
+
+
+ +
+

CSS Selectors

+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+

Advanced

+
+
+
+
+
+
+
+ +
+ + Cancel +
+
+ {/if}
- {:else} - {#if error} -
{error}
- {/if} - -
-
-

Basic Information

-
-
- - -
-
- - -
-
- - -
-
- - -

Use {'{query}'} as placeholder

-
-
- - -
-
-
- -
-

CSS Selectors

-
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- -
-

Advanced Settings

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
- - - Cancel - -
-
- {/if} +
diff --git a/src/client/src/routes/admin/stores/[id]/test/+page.svelte b/src/client/src/routes/admin/stores/[id]/test/+page.svelte index 9dddd30..345a2e5 100644 --- a/src/client/src/routes/admin/stores/[id]/test/+page.svelte +++ b/src/client/src/routes/admin/stores/[id]/test/+page.svelte @@ -19,182 +19,167 @@ 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)}`; } } -
+
{#if loading} -
-
-
+
+
+
{:else if store} -
- -
-

Test: {store.name}

-

{store.base_url}

+ +
+
+ +
+

Test: {store.name}

+

{store.base_url}

+
- - {#if store.health} -
-
-
{store.health.total}
-
Total Scrapes
+
+ + {#if store.health} +
+
+
{store.health.total}
+
Total Scrapes
+
+
+
{store.health.successful}
+
Successful
+
+
+
{store.health.failed}
+
Failed
+
+
+
{store.health.avg_duration_ms}ms
+
Avg Duration
+
-
-
{store.health.successful}
-
Successful
-
-
-
{store.health.failed}
-
Failed
-
-
-
{store.health.avg_duration_ms}ms
-
Avg Duration
-
-
- {/if} + {/if} - -
-
- - -
-
+ - - {#if result} -
- -
-
- {result.success ? `Found ${result.itemsFound} products` : 'Test failed'} + + {#if result} + +
+
+ + + {result.success ? `Found ${result.itemsFound} products` : 'Test failed'} +
{#if result.searchUrl} -
URL: {result.searchUrl}
+

{result.searchUrl}

{/if} {#if result.duration} -
Duration: {result.duration}ms
+

{result.duration}ms

{/if} {#if result.error} -
{result.error}
+

{result.error}

{/if}
{#if result.success} - -
-

Parsed Products ({result.parsedProducts?.length || 0})

- {#if result.parsedProducts?.length > 0} -
- - - - - - - - - - - {#each result.parsedProducts as product} - - - - - - - {/each} - -
NamePriceRaw PriceLink
{product.name}{formatPrice(product.price, product.currency)}{product.priceText} - - {product.url} - -
+ + {#if result.parsedProducts?.length > 0} +
+
+

Parsed Products ({result.parsedProducts.length})

- {:else} -

No products parsed. Check your CSS selectors.

- {/if} -
+ + + + + + + + + + + {#each result.parsedProducts as product} + + + + + + + {/each} + +
NamePriceRawLink
{product.name}{formatPrice(product.price, product.currency)}{product.priceText} + {product.url} +
+
+ {:else} +
+ No products parsed. Check your CSS selectors. +
+ {/if} - -
- + +
+ Raw HTML Preview ({result.rawHtmlLength?.toLocaleString()} bytes) -
-
{result.rawHtmlPreview}
+
+
{result.rawHtmlPreview}
{/if} {#if result.recentLogs?.length > 0} -
-

Recent Scrape Logs

-
- - - - - - - - - - - - {#each result.recentLogs as log} - - - - - - - - {/each} - -
StatusQueryResultsDurationTime
- - {log.query}{log.result_count}{log.duration_ms}ms{log.scraped_at}
+
+
+

Recent Scrape Logs

+ + + + + + + + + + + + {#each result.recentLogs as log} + + + + + + + + {/each} + +
QueryResultsDurationTime
{log.query}{log.result_count}{log.duration_ms}ms{log.scraped_at}
{/if} -
- {/if} + {/if} +
{/if}
diff --git a/src/client/src/routes/admin/stores/new/+page.svelte b/src/client/src/routes/admin/stores/new/+page.svelte index 759f823..345c93d 100644 --- a/src/client/src/routes/admin/stores/new/+page.svelte +++ b/src/client/src/routes/admin/stores/new/+page.svelte @@ -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; } } -
-

Add New Store

- - {#if error} -
{error}
- {/if} - -
- -
-

Basic Information

-
-
- - -
-
- - -
-
- - -
-
- - -

Use {'{query}'} as placeholder for the search term

-
-
- - -
-
-
- - -
-

CSS Selectors

-

Define how to extract product data from the store's search results page.

-
-
- - -

Selector for each product listing item

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-

Advanced Settings

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
- - - Cancel - +
+
+
+ +

Add New Store

- +
+ +
+
+ {#if error} +
{error}
+ {/if} + +
+
+

Basic Information

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +

Use {'{query}'} as placeholder for the search term

+
+
+ + +
+
+
+ +
+

CSS Selectors

+

Define how to extract product data from the store's search results page.

+
+
+ + +

Selector for each product listing item

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+

Advanced

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + Cancel +
+
+
+
diff --git a/src/client/src/routes/results/+page.svelte b/src/client/src/routes/results/+page.svelte index 04a63bf..2c7f7c3 100644 --- a/src/client/src/routes/results/+page.svelte +++ b/src/client/src/routes/results/+page.svelte @@ -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,134 +47,110 @@ 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)}`; } } -
- -
-
- - -
-
+
+ +
+
+
+ + + + +
+
- - {#if meta} -
-

- {meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms -

-
- {/if} +
{#if meta?.errors?.length > 0} -
+
{#each meta.errors as err} -
+
{err.storeName}: {err.error}
{/each}
{/if} - - {#if loading} -
- {#each Array(8) as _} -
-
-
-
-
- {/each} -
- {:else if sortedResults().length === 0} -
-

No results found for "{query}"

-

Try a different search term or check your store configurations.

-
- {:else} - -
- {#each sortedResults() as product} - - {#if product.image} -
- {product.name} -
- {:else} -
- ? -
- {/if} - -
-

- {product.name} -

-
- - {formatPrice(product.price, product.currency)} - - - {product.storeName} - -
+ +
+ {:else if sortedResults().length === 0} +
+ + + +

No results found for "{query}"

+

Try a different search term or check your store configurations.

+
+ {:else} + + {/if} +
diff --git a/src/client/tailwind.config.js b/src/client/tailwind.config.js index 8aabfb0..7cec51c 100644 --- a/src/client/tailwind.config.js +++ b/src/client/tailwind.config.js @@ -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: [], };