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:
@@ -2,12 +2,61 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
--color-primary: #2563eb;
|
|
||||||
--color-primary-hover: #1d4ed8;
|
@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 {
|
@layer components {
|
||||||
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100;
|
.btn-primary {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#0a0a0b" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<title>Price Hunter</title>
|
<title>Price Hunter</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
|||||||
@@ -1,22 +1,69 @@
|
|||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import { page } from '$app/stores';
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
function isActive(path) {
|
||||||
|
const current = $page.url.pathname;
|
||||||
|
if (path === '/') return current === '/';
|
||||||
|
return current.startsWith(path);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<nav class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3">
|
<!-- Sidebar -->
|
||||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
<aside class="w-56 flex-shrink-0 bg-surface border-r border-surface-border flex flex-col">
|
||||||
<a href="/" class="text-xl font-bold text-blue-600 dark:text-blue-400 hover:text-blue-700">
|
<!-- Logo -->
|
||||||
Price Hunter
|
<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>
|
</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>
|
</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>
|
</nav>
|
||||||
<main class="flex-1">
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 overflow-y-auto bg-surface">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,36 +25,43 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-center min-h-[80vh] px-4">
|
<div class="flex flex-col items-center justify-center min-h-full px-4">
|
||||||
<h1 class="text-5xl font-bold text-gray-900 dark:text-white mb-2">Price Hunter</h1>
|
<div class="w-full max-w-xl -mt-20">
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-8">Search across all your stores at once</p>
|
<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">
|
<form onsubmit={handleSearch}>
|
||||||
<div class="relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder="Search for a product..."
|
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
|
autofocus
|
||||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-white
|
class="w-full pl-10 pr-20 py-2.5 bg-surface-raised border border-surface-border rounded-lg
|
||||||
focus:border-blue-500 focus:outline-none shadow-sm hover:shadow-md transition-shadow"
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700 text-white
|
class="absolute right-1.5 top-1/2 -translate-y-1/2 btn-primary py-1.5 px-3.5 text-xs"
|
||||||
px-6 py-2.5 rounded-full font-medium transition-colors"
|
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if categories.length > 0 || groups.length > 0}
|
{#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}
|
{#if categories.length > 0}
|
||||||
<select
|
<select
|
||||||
bind:value={selectedCategory}
|
bind:value={selectedCategory}
|
||||||
class="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg
|
class="input-field w-auto text-xs py-1.5 px-2.5"
|
||||||
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
>
|
||||||
<option value="">All categories</option>
|
<option value="">All categories</option>
|
||||||
{#each categories as cat}
|
{#each categories as cat}
|
||||||
@@ -66,8 +73,7 @@
|
|||||||
{#if groups.length > 0}
|
{#if groups.length > 0}
|
||||||
<select
|
<select
|
||||||
bind:value={selectedGroup}
|
bind:value={selectedGroup}
|
||||||
class="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg
|
class="input-field w-auto text-xs py-1.5 px-2.5"
|
||||||
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
>
|
||||||
<option value="">All groups</option>
|
<option value="">All groups</option>
|
||||||
{#each groups as group}
|
{#each groups as group}
|
||||||
@@ -78,4 +84,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,98 +42,93 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
<div class="h-full flex flex-col">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<!-- Header -->
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Store Management</h1>
|
<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">
|
<div class="flex gap-2">
|
||||||
<button onclick={handleSync}
|
<button onclick={handleSync} class="btn-secondary text-xs py-1" title="Reload from YAML files">
|
||||||
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">
|
|
||||||
Sync from Files
|
Sync from Files
|
||||||
</button>
|
</button>
|
||||||
<button onclick={handleExport}
|
<button onclick={handleExport} class="btn-secondary text-xs py-1" title="Export all to YAML">
|
||||||
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">
|
|
||||||
Export to Files
|
Export to Files
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a href="/admin/stores/new" class="btn-primary text-xs py-1">Add Store</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if syncMessage}
|
{#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}
|
{syncMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="animate-pulse space-y-3">
|
<div class="p-6 space-y-2">
|
||||||
{#each Array(5) as _}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if stores.length === 0}
|
{:else if stores.length === 0}
|
||||||
<div class="text-center py-20 text-gray-500 dark:text-gray-400">
|
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
|
||||||
<p class="text-lg mb-2">No stores configured yet</p>
|
<svg class="w-10 h-10 mb-3 text-text-tertiary/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
<a href="/admin/stores/new" class="text-blue-600 hover:underline">Add your first store</a>
|
<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>
|
</div>
|
||||||
{:else}
|
{: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">
|
<table class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-50 dark:bg-gray-800 text-left text-sm text-gray-600 dark:text-gray-400">
|
<tr class="border-b border-surface-border text-left">
|
||||||
<th class="px-4 py-3 font-medium">Name</th>
|
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider">Name</th>
|
||||||
<th class="px-4 py-3 font-medium">URL</th>
|
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider">URL</th>
|
||||||
<th class="px-4 py-3 font-medium">Category</th>
|
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider">Category</th>
|
||||||
<th class="px-4 py-3 font-medium text-center">Enabled</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-4 py-3 font-medium text-right">Actions</th>
|
<th class="px-6 py-2.5 text-2xs font-medium text-text-tertiary uppercase tracking-wider text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each stores as store}
|
{#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">
|
<tr class="border-b border-surface-border hover:bg-surface-hover/50 transition-colors duration-100">
|
||||||
<td class="px-4 py-3">
|
<td class="px-6 py-2.5">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{store.name}</span>
|
<span class="text-sm font-medium text-text-primary">{store.name}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
|
<td class="px-6 py-2.5 text-xs text-text-tertiary truncate max-w-xs">{store.base_url}</td>
|
||||||
{store.base_url}
|
<td class="px-6 py-2.5">
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
{#if store.category_name}
|
{#if store.category_name}
|
||||||
<span
|
<span class="text-2xs px-2 py-0.5 rounded-full font-medium"
|
||||||
class="text-xs px-2 py-0.5 rounded-full text-white"
|
style="background-color: {store.category_color}20; color: {store.category_color}">
|
||||||
style="background-color: {store.category_color}"
|
|
||||||
>
|
|
||||||
{store.category_name}
|
{store.category_name}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xs text-gray-400">—</span>
|
<span class="text-2xs text-text-tertiary">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-6 py-2.5 text-center">
|
||||||
<button
|
<button onclick={() => handleToggle(store.id)}
|
||||||
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
|
||||||
class="w-10 h-5 rounded-full transition-colors {store.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'} relative"
|
{store.enabled
|
||||||
>
|
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
||||||
<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>
|
: '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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-6 py-2.5 text-right">
|
||||||
<div class="flex gap-2 justify-end">
|
<div class="flex gap-1 justify-end">
|
||||||
<a href="/admin/stores/{store.id}" class="text-sm text-blue-600 hover:underline">Edit</a>
|
<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="text-sm text-green-600 hover:underline">Test</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="text-sm text-red-600 hover:underline">Delete</button>
|
<button onclick={() => handleDelete(store.id, store.name)} class="btn-danger text-xs py-0.5 px-2">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,10 +11,9 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
let newCatName = $state('');
|
let newCatName = $state('');
|
||||||
let newCatColor = $state('#6B7280');
|
let newCatColor = $state('#8b5cf6');
|
||||||
let newGroupName = $state('');
|
let newGroupName = $state('');
|
||||||
let editingCat = $state(null);
|
let editingCat = $state(null);
|
||||||
let editingGroup = $state(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
[categories, groups, stores] = await Promise.all([getCategories(), getGroups(), getStores()]);
|
[categories, groups, stores] = await Promise.all([getCategories(), getGroups(), getStores()]);
|
||||||
@@ -26,7 +25,7 @@
|
|||||||
const cat = await createCategory(newCatName.trim(), newCatColor);
|
const cat = await createCategory(newCatName.trim(), newCatColor);
|
||||||
categories = [...categories, cat];
|
categories = [...categories, cat];
|
||||||
newCatName = '';
|
newCatName = '';
|
||||||
newCatColor = '#6B7280';
|
newCatColor = '#8b5cf6';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateCategory(id) {
|
async function handleUpdateCategory(id) {
|
||||||
@@ -58,53 +57,57 @@
|
|||||||
async function handleToggleGroupMember(groupId, storeId) {
|
async function handleToggleGroupMember(groupId, storeId) {
|
||||||
const group = groups.find((g) => g.id === groupId);
|
const group = groups.find((g) => g.id === groupId);
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
|
|
||||||
const ids = group.store_ids.includes(storeId)
|
const ids = group.store_ids.includes(storeId)
|
||||||
? group.store_ids.filter((id) => id !== storeId)
|
? group.store_ids.filter((id) => id !== storeId)
|
||||||
: [...group.store_ids, storeId];
|
: [...group.store_ids, storeId];
|
||||||
|
|
||||||
await setGroupMembersApi(groupId, ids);
|
await setGroupMembersApi(groupId, ids);
|
||||||
groups = groups.map((g) => (g.id === groupId ? { ...g, store_ids: ids } : g));
|
groups = groups.map((g) => (g.id === groupId ? { ...g, store_ids: ids } : g));
|
||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if loading}
|
||||||
<div class="animate-pulse space-y-4">
|
<div class="space-y-4 animate-pulse">
|
||||||
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
|
<div class="bg-surface-raised h-40 rounded-lg"></div>
|
||||||
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
|
<div class="bg-surface-raised h-40 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="max-w-2xl space-y-8">
|
||||||
<!-- Categories -->
|
<!-- Categories -->
|
||||||
<section class="mb-10">
|
<section>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Categories</h1>
|
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-3">Categories</h2>
|
||||||
|
|
||||||
<div class="flex gap-2 mb-4">
|
<div class="flex gap-2 mb-3">
|
||||||
<input type="text" bind:value={newCatName} placeholder="Category name"
|
<input type="text" bind:value={newCatName} placeholder="Category name" class="input-field flex-1" />
|
||||||
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" />
|
|
||||||
<input type="color" bind:value={newCatColor}
|
<input type="color" bind:value={newCatColor}
|
||||||
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer" />
|
class="w-9 h-9 rounded border border-surface-border cursor-pointer bg-surface-raised" />
|
||||||
<button onclick={handleAddCategory}
|
<button onclick={handleAddCategory} class="btn-primary text-xs">Add</button>
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Add</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if categories.length === 0}
|
{#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}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-1">
|
||||||
{#each categories as cat}
|
{#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}
|
{#if editingCat?.id === cat.id}
|
||||||
<input type="color" bind:value={editingCat.color} class="w-8 h-8 rounded cursor-pointer" />
|
<input type="color" bind:value={editingCat.color}
|
||||||
<input type="text" bind:value={editingCat.name}
|
class="w-6 h-6 rounded cursor-pointer bg-transparent border-0" />
|
||||||
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" />
|
<input type="text" bind:value={editingCat.name} class="input-field flex-1 py-1" />
|
||||||
<button onclick={() => handleUpdateCategory(cat.id)} class="text-sm text-blue-600 hover:underline">Save</button>
|
<button onclick={() => handleUpdateCategory(cat.id)} class="text-2xs text-accent-text hover:text-accent">Save</button>
|
||||||
<button onclick={() => editingCat = null} class="text-sm text-gray-500 hover:underline">Cancel</button>
|
<button onclick={() => editingCat = null} class="text-2xs text-text-tertiary hover:text-text-secondary">Cancel</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="w-4 h-4 rounded-full" style="background-color: {cat.color}"></span>
|
<span class="w-3 h-3 rounded-sm flex-shrink-0" style="background-color: {cat.color}"></span>
|
||||||
<span class="flex-1 text-gray-900 dark:text-white">{cat.name}</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-sm text-blue-600 hover:underline">Edit</button>
|
<button onclick={() => editingCat = { id: cat.id, name: cat.name, color: cat.color }}
|
||||||
<button onclick={() => handleDeleteCategory(cat.id, cat.name)} class="text-sm text-red-600 hover:underline">Delete</button>
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -114,33 +117,32 @@
|
|||||||
|
|
||||||
<!-- Groups -->
|
<!-- Groups -->
|
||||||
<section>
|
<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">
|
<div class="flex gap-2 mb-3">
|
||||||
<input type="text" bind:value={newGroupName} placeholder="Group name"
|
<input type="text" bind:value={newGroupName} placeholder="Group name" class="input-field flex-1" />
|
||||||
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="btn-primary text-xs">Add</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{#if groups.length === 0}
|
{#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}
|
{:else}
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
{#each groups as group}
|
{#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">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h3 class="font-medium text-gray-900 dark:text-white">{group.name}</h3>
|
<h3 class="text-sm font-medium text-text-primary">{group.name}</h3>
|
||||||
<button onclick={() => handleDeleteGroup(group.id, group.name)} class="text-sm text-red-600 hover:underline">Delete</button>
|
<button onclick={() => handleDeleteGroup(group.id, group.name)}
|
||||||
|
class="text-2xs text-text-tertiary hover:text-red-400">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#each stores as store}
|
{#each stores as store}
|
||||||
<button
|
<button
|
||||||
onclick={() => handleToggleGroupMember(group.id, store.id)}
|
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)
|
{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-accent-muted border-accent/30 text-accent-text'
|
||||||
: '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-surface border-surface-border text-text-tertiary hover:text-text-secondary hover:border-surface-border-hover'}"
|
||||||
>
|
>
|
||||||
{store.name}
|
{store.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -151,5 +153,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,42 +10,22 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
let form = $state({
|
let form = $state({
|
||||||
name: '',
|
name: '', base_url: '', search_url: '',
|
||||||
base_url: '',
|
sel_container: '', sel_name: '', sel_price: '', sel_link: '', sel_image: '',
|
||||||
search_url: '',
|
category_id: '', currency: 'EUR', rate_limit: 2,
|
||||||
sel_container: '',
|
user_agent: '', proxy_url: '', headers_json: '',
|
||||||
sel_name: '',
|
|
||||||
sel_price: '',
|
|
||||||
sel_link: '',
|
|
||||||
sel_image: '',
|
|
||||||
category_id: '',
|
|
||||||
currency: 'EUR',
|
|
||||||
rate_limit: 2,
|
|
||||||
user_agent: '',
|
|
||||||
proxy_url: '',
|
|
||||||
headers_json: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const id = $page.params.id;
|
const [store, cats] = await Promise.all([getStore(Number($page.params.id)), getCategories()]);
|
||||||
const [store, cats] = await Promise.all([getStore(Number(id)), getCategories()]);
|
|
||||||
categories = cats;
|
categories = cats;
|
||||||
|
|
||||||
form = {
|
form = {
|
||||||
name: store.name,
|
name: store.name, base_url: store.base_url, search_url: store.search_url,
|
||||||
base_url: store.base_url,
|
sel_container: store.sel_container, sel_name: store.sel_name, sel_price: store.sel_price,
|
||||||
search_url: store.search_url,
|
sel_link: store.sel_link, sel_image: store.sel_image || '',
|
||||||
sel_container: store.sel_container,
|
category_id: store.category_id?.toString() || '', currency: store.currency,
|
||||||
sel_name: store.sel_name,
|
rate_limit: store.rate_limit, user_agent: store.user_agent || '',
|
||||||
sel_price: store.sel_price,
|
proxy_url: store.proxy_url || '', headers_json: store.headers_json || '',
|
||||||
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;
|
loading = false;
|
||||||
});
|
});
|
||||||
@@ -54,147 +34,100 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = '';
|
||||||
saving = true;
|
saving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = { ...form };
|
const data = { ...form };
|
||||||
if (data.category_id) data.category_id = Number(data.category_id);
|
if (data.category_id) data.category_id = Number(data.category_id); else delete data.category_id;
|
||||||
else delete data.category_id;
|
|
||||||
|
|
||||||
await updateStore(Number($page.params.id), data);
|
await updateStore(Number($page.params.id), data);
|
||||||
goto('/admin');
|
goto('/admin');
|
||||||
} catch (err) {
|
} catch (err) { error = err.message || 'Failed to update store'; }
|
||||||
error = err.message || 'Failed to update store';
|
finally { saving = false; }
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
<div class="h-full flex flex-col">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3 flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Edit Store</h1>
|
<div class="flex items-center gap-3">
|
||||||
<a href="/admin/stores/{$page.params.id}/test"
|
<a href="/admin" class="text-text-tertiary hover:text-text-secondary transition-colors">←</a>
|
||||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
|
<h1 class="text-sm font-semibold text-text-primary">Edit Store</h1>
|
||||||
Test Store
|
</div>
|
||||||
</a>
|
<a href="/admin/stores/{$page.params.id}/test" class="btn-secondary text-xs py-1">Test Store</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<div class="max-w-2xl">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="animate-pulse space-y-4">
|
<div class="space-y-4 animate-pulse">
|
||||||
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
|
<div class="bg-surface-raised h-48 rounded-lg"></div>
|
||||||
<div class="bg-gray-200 dark:bg-gray-800 h-60 rounded-lg"></div>
|
<div class="bg-surface-raised h-48 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if error}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-6">
|
<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">
|
<section class="card p-5">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
|
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">Basic Information</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Store Name *</label>
|
<label class="label">Store Name</label>
|
||||||
<input type="text" bind:value={form.name} required
|
<input type="text" bind:value={form.name} required class="input-field" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
<label class="label">Category</label>
|
||||||
<select bind:value={form.category_id}
|
<select bind:value={form.category_id} class="input-field">
|
||||||
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">
|
|
||||||
<option value="">No category</option>
|
<option value="">No category</option>
|
||||||
{#each categories as cat}
|
{#each categories as cat}<option value={cat.id}>{cat.name}</option>{/each}
|
||||||
<option value={cat.id}>{cat.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base URL *</label>
|
<label class="label">Base URL</label>
|
||||||
<input type="url" bind:value={form.base_url} required
|
<input type="url" bind:value={form.base_url} required class="input-field" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search URL *</label>
|
<label class="label">Search URL</label>
|
||||||
<input type="text" bind:value={form.search_url} required
|
<input type="text" bind:value={form.search_url} required class="input-field" />
|
||||||
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-2xs text-text-tertiary mt-1">Use {'{query}'} as placeholder</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">Use {'{query}'} as placeholder</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
|
<label class="label">Currency</label>
|
||||||
<input type="text" bind:value={form.currency}
|
<input type="text" bind:value={form.currency} class="input-field" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
<section class="card p-5">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">CSS Selectors</h2>
|
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">CSS Selectors</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Container *</label>
|
<label class="label">Product Container</label>
|
||||||
<input type="text" bind:value={form.sel_container} required
|
<input type="text" bind:value={form.sel_container} required class="input-field-mono" />
|
||||||
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" />
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
<section class="card p-5">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Advanced Settings</h2>
|
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">Advanced</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div><label class="label">Rate Limit</label><input type="number" bind:value={form.rate_limit} min="1" max="10" class="input-field" /></div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rate Limit</label>
|
<div><label class="label">User Agent</label><input type="text" bind:value={form.user_agent} class="input-field" /></div>
|
||||||
<input type="number" bind:value={form.rate_limit} min="1" max="10"
|
<div><label class="label">Proxy URL</label><input type="text" bind:value={form.proxy_url} class="input-field" /></div>
|
||||||
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><label class="label">Extra Headers (JSON)</label><input type="text" bind:value={form.headers_json} class="input-field-mono" /></div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-2">
|
||||||
<button type="submit" disabled={saving}
|
<button type="submit" disabled={saving} class="btn-primary {saving ? 'opacity-50' : ''}">{saving ? 'Saving...' : 'Save Changes'}</button>
|
||||||
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2.5 rounded-lg font-medium">
|
<a href="/admin" class="btn-secondary">Cancel</a>
|
||||||
{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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,127 +19,113 @@
|
|||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
testing = true;
|
testing = true;
|
||||||
result = null;
|
result = null;
|
||||||
|
try { result = await testStore(Number($page.params.id), query.trim()); }
|
||||||
try {
|
catch (err) { result = { success: false, error: err.message }; }
|
||||||
result = await testStore(Number($page.params.id), query.trim());
|
finally { testing = false; }
|
||||||
} catch (err) {
|
|
||||||
result = { success: false, error: err.message };
|
|
||||||
} finally {
|
|
||||||
testing = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(price, currency) {
|
function formatPrice(price, currency) {
|
||||||
if (price === null || price === undefined) return 'N/A';
|
if (price === null || price === undefined) return 'N/A';
|
||||||
try {
|
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price); }
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price);
|
catch { return `${currency} ${price.toFixed(2)}`; }
|
||||||
} catch {
|
|
||||||
return `${currency} ${price.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
<div class="h-full flex flex-col">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="animate-pulse">
|
<div class="p-6 animate-pulse">
|
||||||
<div class="bg-gray-200 dark:bg-gray-800 h-8 w-48 rounded mb-4"></div>
|
<div class="bg-surface-raised 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="bg-surface-raised h-10 rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if store}
|
{:else if store}
|
||||||
<div class="flex items-center gap-4 mb-6">
|
<!-- Header -->
|
||||||
<a href="/admin/stores/{store.id}" class="text-gray-400 hover:text-gray-600">←</a>
|
<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">←</a>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Test: {store.name}</h1>
|
<h1 class="text-sm font-semibold text-text-primary">Test: {store.name}</h1>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{store.base_url}</p>
|
<p class="text-2xs text-text-tertiary">{store.base_url}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6 space-y-5">
|
||||||
<!-- Health Stats -->
|
<!-- Health Stats -->
|
||||||
{#if store.health}
|
{#if store.health}
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
<div class="grid grid-cols-4 gap-3">
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
<div class="card px-4 py-3">
|
||||||
<div class="text-2xl font-bold text-gray-900 dark:text-white">{store.health.total}</div>
|
<div class="text-lg font-semibold text-text-primary">{store.health.total}</div>
|
||||||
<div class="text-xs text-gray-500">Total Scrapes</div>
|
<div class="text-2xs text-text-tertiary">Total Scrapes</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
<div class="card px-4 py-3">
|
||||||
<div class="text-2xl font-bold text-green-600">{store.health.successful}</div>
|
<div class="text-lg font-semibold text-emerald-400">{store.health.successful}</div>
|
||||||
<div class="text-xs text-gray-500">Successful</div>
|
<div class="text-2xs text-text-tertiary">Successful</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
<div class="card px-4 py-3">
|
||||||
<div class="text-2xl font-bold text-red-600">{store.health.failed}</div>
|
<div class="text-lg font-semibold text-red-400">{store.health.failed}</div>
|
||||||
<div class="text-xs text-gray-500">Failed</div>
|
<div class="text-2xs text-text-tertiary">Failed</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
<div class="card px-4 py-3">
|
||||||
<div class="text-2xl font-bold text-gray-900 dark:text-white">{store.health.avg_duration_ms}ms</div>
|
<div class="text-lg font-semibold text-text-primary">{store.health.avg_duration_ms}ms</div>
|
||||||
<div class="text-xs text-gray-500">Avg Duration</div>
|
<div class="text-2xs text-text-tertiary">Avg Duration</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Test Form -->
|
<!-- Test Form -->
|
||||||
<form onsubmit={handleTest} class="mb-6">
|
<form onsubmit={handleTest} class="flex gap-2">
|
||||||
<div class="flex gap-2">
|
<input type="text" bind:value={query} placeholder="Enter a test search query..."
|
||||||
<input
|
class="input-field flex-1" />
|
||||||
type="text"
|
<button type="submit" disabled={testing} class="btn-primary {testing ? 'opacity-50' : ''}">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{testing ? 'Testing...' : 'Run Test'}
|
{testing ? 'Testing...' : 'Run Test'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Test Results -->
|
<!-- Results -->
|
||||||
{#if result}
|
{#if result}
|
||||||
<div class="space-y-6">
|
<!-- Status -->
|
||||||
<!-- Status Banner -->
|
<div class="card px-4 py-3 {result.success ? 'border-emerald-500/20' : 'border-red-500/20'}">
|
||||||
<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="flex items-center gap-2 mb-1">
|
||||||
<div class="font-medium">
|
<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'}
|
{result.success ? `Found ${result.itemsFound} products` : 'Test failed'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if result.searchUrl}
|
{#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}
|
||||||
{#if result.duration}
|
{#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}
|
||||||
{#if result.error}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if result.success}
|
{#if result.success}
|
||||||
<!-- Parsed Results -->
|
<!-- Parsed Products Table -->
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Parsed Products ({result.parsedProducts?.length || 0})</h2>
|
|
||||||
{#if result.parsedProducts?.length > 0}
|
{#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">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<thead>
|
||||||
<tr class="bg-gray-50 dark:bg-gray-800 text-left">
|
<tr class="border-b border-surface-border">
|
||||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Name</th>
|
<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 font-medium text-gray-600 dark:text-gray-400">Price</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 font-medium text-gray-600 dark:text-gray-400">Raw 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 font-medium text-gray-600 dark:text-gray-400">Link</th>
|
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Link</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each result.parsedProducts as product}
|
{#each result.parsedProducts as product}
|
||||||
<tr class="border-t border-gray-100 dark:border-gray-800">
|
<tr class="border-b border-surface-border hover:bg-surface-hover/50 transition-colors">
|
||||||
<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 text-text-primary 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 font-medium text-accent-text">{formatPrice(product.price, product.currency)}</td>
|
||||||
<td class="px-4 py-2 text-gray-500 font-mono text-xs">{product.priceText}</td>
|
<td class="px-4 py-2 text-text-tertiary font-mono">{product.priceText}</td>
|
||||||
<td class="px-4 py-2">
|
<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">
|
<a href={product.url} target="_blank" rel="noopener" class="text-accent-text hover:text-accent truncate block max-w-xs">{product.url}</a>
|
||||||
{product.url}
|
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -147,54 +133,53 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-gray-500 dark:text-gray-400">No products parsed. Check your CSS selectors.</p>
|
<div class="card px-4 py-6 text-center text-text-tertiary text-sm">
|
||||||
{/if}
|
No products parsed. Check your CSS selectors.
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Raw HTML Preview -->
|
<!-- Raw HTML -->
|
||||||
<details class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
<details class="card">
|
||||||
<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">
|
<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)
|
Raw HTML Preview ({result.rawHtmlLength?.toLocaleString()} bytes)
|
||||||
</summary>
|
</summary>
|
||||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-800">
|
<div class="px-4 py-3 border-t border-surface-border">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Recent Logs -->
|
<!-- Recent Logs -->
|
||||||
{#if result.recentLogs?.length > 0}
|
{#if result.recentLogs?.length > 0}
|
||||||
<div>
|
<div class="card overflow-hidden">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Recent Scrape Logs</h2>
|
<div class="px-4 py-2.5 border-b border-surface-border">
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
|
<h2 class="text-xs font-semibold text-text-primary">Recent Scrape Logs</h2>
|
||||||
<table class="w-full text-sm">
|
</div>
|
||||||
|
<table class="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-50 dark:bg-gray-800 text-left">
|
<tr class="border-b border-surface-border">
|
||||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Status</th>
|
<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 font-medium text-gray-600 dark:text-gray-400">Query</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 font-medium text-gray-600 dark:text-gray-400">Results</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 font-medium text-gray-600 dark:text-gray-400">Duration</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 font-medium text-gray-600 dark:text-gray-400">Time</th>
|
<th class="px-4 py-2 text-left text-2xs font-medium text-text-tertiary uppercase tracking-wider">Time</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each result.recentLogs as log}
|
{#each result.recentLogs as log}
|
||||||
<tr class="border-t border-gray-100 dark:border-gray-800">
|
<tr class="border-b border-surface-border">
|
||||||
<td class="px-4 py-2">
|
<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>
|
||||||
<span class="inline-block w-2 h-2 rounded-full {log.success ? 'bg-green-500' : 'bg-red-500'}"></span>
|
<td class="px-4 py-2 text-text-secondary">{log.query}</td>
|
||||||
</td>
|
<td class="px-4 py-2 text-text-secondary">{log.result_count}</td>
|
||||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{log.query}</td>
|
<td class="px-4 py-2 text-text-tertiary">{log.duration_ms}ms</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-text-tertiary">{log.scraped_at}</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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,167 +8,136 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
let form = $state({
|
let form = $state({
|
||||||
name: '',
|
name: '', base_url: '', search_url: '',
|
||||||
base_url: '',
|
sel_container: '', sel_name: '', sel_price: '', sel_link: '', sel_image: '',
|
||||||
search_url: '',
|
category_id: '', currency: 'EUR', rate_limit: 2,
|
||||||
sel_container: '',
|
user_agent: '', proxy_url: '', headers_json: '',
|
||||||
sel_name: '',
|
|
||||||
sel_price: '',
|
|
||||||
sel_link: '',
|
|
||||||
sel_image: '',
|
|
||||||
category_id: '',
|
|
||||||
currency: 'EUR',
|
|
||||||
rate_limit: 2,
|
|
||||||
user_agent: '',
|
|
||||||
proxy_url: '',
|
|
||||||
headers_json: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => { categories = await getCategories(); });
|
||||||
categories = await getCategories();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = '';
|
||||||
saving = true;
|
saving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = { ...form };
|
const data = { ...form };
|
||||||
if (data.category_id) data.category_id = Number(data.category_id);
|
if (data.category_id) data.category_id = Number(data.category_id); else delete data.category_id;
|
||||||
else delete data.category_id;
|
|
||||||
if (!data.sel_image) delete data.sel_image;
|
if (!data.sel_image) delete data.sel_image;
|
||||||
if (!data.user_agent) delete data.user_agent;
|
if (!data.user_agent) delete data.user_agent;
|
||||||
if (!data.proxy_url) delete data.proxy_url;
|
if (!data.proxy_url) delete data.proxy_url;
|
||||||
if (!data.headers_json) delete data.headers_json;
|
if (!data.headers_json) delete data.headers_json;
|
||||||
|
|
||||||
const store = await createStore(data);
|
const store = await createStore(data);
|
||||||
goto(`/admin/stores/${store.id}/test`);
|
goto(`/admin/stores/${store.id}/test`);
|
||||||
} catch (err) {
|
} catch (err) { error = err.message || 'Failed to create store'; }
|
||||||
error = err.message || 'Failed to create store';
|
finally { saving = false; }
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
<div class="h-full flex flex-col">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Add New Store</h1>
|
<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">←</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}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-6">
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
<!-- Basic Info -->
|
<section class="card p-5">
|
||||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">Basic Information</h2>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Store Name *</label>
|
<label class="label">Store Name</label>
|
||||||
<input type="text" bind:value={form.name} required
|
<input type="text" bind:value={form.name} required class="input-field" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
<label class="label">Category</label>
|
||||||
<select bind:value={form.category_id}
|
<select bind:value={form.category_id} class="input-field">
|
||||||
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">
|
|
||||||
<option value="">No category</option>
|
<option value="">No category</option>
|
||||||
{#each categories as cat}
|
{#each categories as cat}<option value={cat.id}>{cat.name}</option>{/each}
|
||||||
<option value={cat.id}>{cat.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base URL *</label>
|
<label class="label">Base URL</label>
|
||||||
<input type="url" bind:value={form.base_url} required placeholder="https://store.example.com"
|
<input type="url" bind:value={form.base_url} required placeholder="https://store.example.com" class="input-field" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search URL *</label>
|
<label class="label">Search URL</label>
|
||||||
<input type="text" bind:value={form.search_url} required placeholder={'https://store.example.com/search?q={query}'}
|
<input type="text" bind:value={form.search_url} required placeholder={'https://store.example.com/search?q={query}'} class="input-field" />
|
||||||
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-2xs text-text-tertiary mt-1">Use {'{query}'} as placeholder for the search term</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">Use {'{query}'} as placeholder for the search term</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
|
<label class="label">Currency</label>
|
||||||
<input type="text" bind:value={form.currency} placeholder="EUR"
|
<input type="text" bind:value={form.currency} placeholder="EUR" class="input-field" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- CSS Selectors -->
|
<section class="card p-5">
|
||||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-1">CSS Selectors</h2>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">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>
|
||||||
<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-3">
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Container *</label>
|
<label class="label">Product Container</label>
|
||||||
<input type="text" bind:value={form.sel_container} required placeholder=".product-card"
|
<input type="text" bind:value={form.sel_container} required placeholder=".product-card" class="input-field-mono" />
|
||||||
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-2xs text-text-tertiary mt-1">Selector for each product listing item</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">Selector for each product listing item</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Name *</label>
|
<label class="label">Product Name</label>
|
||||||
<input type="text" bind:value={form.sel_name} required placeholder=".product-title"
|
<input type="text" bind:value={form.sel_name} required placeholder=".product-title" class="input-field-mono" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Price *</label>
|
<label class="label">Price</label>
|
||||||
<input type="text" bind:value={form.sel_price} required placeholder=".product-price"
|
<input type="text" bind:value={form.sel_price} required placeholder=".product-price" class="input-field-mono" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Link *</label>
|
<label class="label">Product Link</label>
|
||||||
<input type="text" bind:value={form.sel_link} required placeholder="a"
|
<input type="text" bind:value={form.sel_link} required placeholder="a" class="input-field-mono" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image (optional)</label>
|
<label class="label">Image (optional)</label>
|
||||||
<input type="text" bind:value={form.sel_image} placeholder="img"
|
<input type="text" bind:value={form.sel_image} placeholder="img" class="input-field-mono" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Advanced -->
|
<section class="card p-5">
|
||||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
<h2 class="text-xs font-semibold text-text-primary uppercase tracking-wider mb-4">Advanced</h2>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Advanced Settings</h2>
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rate Limit (req/sec)</label>
|
<label class="label">Rate Limit (req/sec)</label>
|
||||||
<input type="number" bind:value={form.rate_limit} min="1" max="10"
|
<input type="number" bind:value={form.rate_limit} min="1" max="10" class="input-field" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Agent</label>
|
<label class="label">User Agent</label>
|
||||||
<input type="text" bind:value={form.user_agent} placeholder="Default browser UA"
|
<input type="text" bind:value={form.user_agent} placeholder="Default browser UA" class="input-field" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Proxy URL</label>
|
<label class="label">Proxy URL</label>
|
||||||
<input type="text" bind:value={form.proxy_url} placeholder="http://user:pass@host:port"
|
<input type="text" bind:value={form.proxy_url} placeholder="http://user:pass@host:port" class="input-field" />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Extra Headers (JSON)</label>
|
<label class="label">Extra Headers (JSON)</label>
|
||||||
<input type="text" bind:value={form.headers_json} placeholder={'{"X-Custom": "value"}'}
|
<input type="text" bind:value={form.headers_json} placeholder={'{"X-Custom": "value"}'} class="input-field-mono" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-2">
|
||||||
<button type="submit" disabled={saving}
|
<button type="submit" disabled={saving} class="btn-primary {saving ? 'opacity-50' : ''}">
|
||||||
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">
|
|
||||||
{saving ? 'Creating...' : 'Create Store'}
|
{saving ? 'Creating...' : 'Create Store'}
|
||||||
</button>
|
</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">
|
<a href="/admin" class="btn-secondary">Cancel</a>
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,19 +13,13 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const params = $page.url.searchParams;
|
const params = $page.url.searchParams;
|
||||||
query = params.get('q') || '';
|
query = params.get('q') || '';
|
||||||
|
if (!query) { goto('/'); return; }
|
||||||
if (!query) {
|
|
||||||
goto('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await doSearch();
|
await doSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = $page.url.searchParams;
|
const params = $page.url.searchParams;
|
||||||
const data = await searchProducts(query, {
|
const data = await searchProducts(query, {
|
||||||
@@ -53,134 +47,110 @@
|
|||||||
let sortedResults = $derived(() => {
|
let sortedResults = $derived(() => {
|
||||||
const sorted = [...results];
|
const sorted = [...results];
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'price':
|
case 'price': sorted.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity)); break;
|
||||||
sorted.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity));
|
case 'price-desc': sorted.sort((a, b) => (b.price ?? -Infinity) - (a.price ?? -Infinity)); break;
|
||||||
break;
|
case 'name': sorted.sort((a, b) => a.name.localeCompare(b.name)); break;
|
||||||
case 'price-desc':
|
case 'store': sorted.sort((a, b) => a.storeName.localeCompare(b.storeName)); break;
|
||||||
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;
|
return sorted;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatPrice(price, currency) {
|
function formatPrice(price, currency) {
|
||||||
if (price === null || price === undefined) return 'N/A';
|
if (price === null || price === undefined) return 'N/A';
|
||||||
try {
|
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price); }
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price);
|
catch { return `${currency} ${price.toFixed(2)}`; }
|
||||||
} catch {
|
|
||||||
return `${currency} ${price.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
<div class="h-full flex flex-col">
|
||||||
<!-- Search bar -->
|
<!-- Header -->
|
||||||
<form onsubmit={handleSearch} class="mb-6">
|
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3 flex items-center gap-4">
|
||||||
<div class="relative max-w-2xl">
|
<form onsubmit={handleSearch} class="flex-1 max-w-lg">
|
||||||
<input
|
<div class="relative group">
|
||||||
type="text"
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary"
|
||||||
bind:value={query}
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
placeholder="Search for a product..."
|
<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" />
|
||||||
class="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full
|
</svg>
|
||||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
|
<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
|
||||||
<button type="submit" class="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700
|
text-text-primary placeholder-text-tertiary focus:border-accent/50 focus:outline-none transition-colors" />
|
||||||
text-white px-4 py-1.5 rounded-full text-sm">Search</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Meta info & sort -->
|
<div class="flex items-center gap-3">
|
||||||
{#if meta}
|
{#if meta}
|
||||||
<div class="flex items-center justify-between mb-4">
|
<span class="text-2xs text-text-tertiary">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms
|
{meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms
|
||||||
</p>
|
</span>
|
||||||
<select
|
{/if}
|
||||||
bind:value={sortBy}
|
<select bind:value={sortBy} class="input-field w-auto text-xs py-1 px-2">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option value="price">Price: Low to High</option>
|
<option value="price">Price: Low to High</option>
|
||||||
<option value="price-desc">Price: High to Low</option>
|
<option value="price-desc">Price: High to Low</option>
|
||||||
<option value="name">Name</option>
|
<option value="name">Name</option>
|
||||||
<option value="store">Store</option>
|
<option value="store">Store</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
<!-- Errors -->
|
<!-- Errors -->
|
||||||
{#if meta?.errors?.length > 0}
|
{#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}
|
{#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}
|
{err.storeName}: {err.error}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
{#if loading}
|
{#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 _}
|
{#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="card p-4 animate-pulse">
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded mb-3"></div>
|
<div class="bg-surface-hover h-32 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-surface-hover h-3.5 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="bg-surface-hover h-5 rounded w-1/3"></div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if sortedResults().length === 0}
|
{:else if sortedResults().length === 0}
|
||||||
<div class="text-center py-20 text-gray-500 dark:text-gray-400">
|
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
|
||||||
<p class="text-lg">No results found for "{query}"</p>
|
<svg class="w-10 h-10 mb-3 text-text-tertiary/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
<p class="text-sm mt-2">Try a different search term or check your store configurations.</p>
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Results grid -->
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{#each sortedResults() as product}
|
{#each sortedResults() as product}
|
||||||
<a
|
<a href={product.url} target="_blank" rel="noopener noreferrer"
|
||||||
href={product.url}
|
class="card overflow-hidden group hover:border-surface-border-hover transition-colors duration-150">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{#if product.image}
|
{#if product.image}
|
||||||
<div class="h-40 bg-gray-100 dark:bg-gray-800 flex items-center justify-center overflow-hidden">
|
<div class="h-32 bg-surface flex items-center justify-center overflow-hidden">
|
||||||
<img
|
<img src={product.image} alt={product.name}
|
||||||
src={product.image}
|
class="max-h-full max-w-full object-contain p-3 group-hover:scale-105 transition-transform duration-200" />
|
||||||
alt={product.name}
|
|
||||||
class="max-h-full max-w-full object-contain p-2 group-hover:scale-105 transition-transform"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-40 bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
<div class="h-32 bg-surface flex items-center justify-center">
|
||||||
<span class="text-gray-400 text-4xl">?</span>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-3">
|
||||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white line-clamp-2 mb-2">
|
<h3 class="text-xs font-medium text-text-primary line-clamp-2 mb-2 leading-relaxed">{product.name}</h3>
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-lg font-bold text-blue-600 dark:text-blue-400">
|
<span class="text-sm font-semibold text-accent-text">{formatPrice(product.price, product.currency)}</span>
|
||||||
{formatPrice(product.price, product.currency)}
|
<span class="text-2xs text-text-tertiary bg-surface px-1.5 py-0.5 rounded">{product.storeName}</span>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,40 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
darkMode: 'media',
|
darkMode: 'class',
|
||||||
theme: {
|
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: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user