Initial commit: Price Hunter — self-hosted price comparison engine
Complete application scaffolding with: - Backend: Node.js + Fastify + sql.js (SQLite) - Frontend: SvelteKit + Tailwind CSS - Scraper engine with parallel fan-out, rate limiting, cheerio-based parsing - Store management with CSS selector config and per-store test pages - Docker setup for single-command deployment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2502
src/client/package-lock.json
generated
Normal file
2502
src/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
src/client/package.json
Normal file
23
src/client/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "pricehunter-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.15.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.6"
|
||||
}
|
||||
}
|
||||
6
src/client/postcss.config.js
Normal file
6
src/client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
13
src/client/src/app.css
Normal file
13
src/client/src/app.css
Normal file
@@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #1d4ed8;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
13
src/client/src/app.html
Normal file
13
src/client/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<title>Price Hunter</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-prerender="true">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
91
src/client/src/lib/api.ts
Normal file
91
src/client/src/lib/api.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const BASE = '';
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, public body: string) {
|
||||
super(`API error ${status}: ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, await res.text());
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getStores() {
|
||||
return api<any[]>('/api/stores');
|
||||
}
|
||||
|
||||
export function getStore(id: number) {
|
||||
return api<any>(`/api/stores/${id}`);
|
||||
}
|
||||
|
||||
export function createStore(data: any) {
|
||||
return api<any>('/api/stores', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function updateStore(id: number, data: any) {
|
||||
return api<any>(`/api/stores/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function toggleStore(id: number) {
|
||||
return api<any>(`/api/stores/${id}/toggle`, { method: 'PATCH' });
|
||||
}
|
||||
|
||||
export function deleteStore(id: number) {
|
||||
return api<void>(`/api/stores/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return api<any[]>('/api/categories');
|
||||
}
|
||||
|
||||
export function createCategory(name: string, color?: string) {
|
||||
return api<any>('/api/categories', { method: 'POST', body: JSON.stringify({ name, color }) });
|
||||
}
|
||||
|
||||
export function updateCategory(id: number, data: any) {
|
||||
return api<any>(`/api/categories/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function deleteCategory(id: number) {
|
||||
return api<void>(`/api/categories/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function getGroups() {
|
||||
return api<any[]>('/api/groups');
|
||||
}
|
||||
|
||||
export function createGroup(name: string, description?: string) {
|
||||
return api<any>('/api/groups', { method: 'POST', body: JSON.stringify({ name, description }) });
|
||||
}
|
||||
|
||||
export function updateGroupApi(id: number, data: any) {
|
||||
return api<any>(`/api/groups/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function setGroupMembersApi(id: number, storeIds: number[]) {
|
||||
return api<any>(`/api/groups/${id}/members`, { method: 'PUT', body: JSON.stringify({ store_ids: storeIds }) });
|
||||
}
|
||||
|
||||
export function deleteGroup(id: number) {
|
||||
return api<void>(`/api/groups/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function searchProducts(query: string, params?: { stores?: string; category?: string; group?: string }) {
|
||||
const searchParams = new URLSearchParams({ q: query });
|
||||
if (params?.stores) searchParams.set('stores', params.stores);
|
||||
if (params?.category) searchParams.set('category', params.category);
|
||||
if (params?.group) searchParams.set('group', params.group);
|
||||
return api<any>(`/api/search?${searchParams}`);
|
||||
}
|
||||
|
||||
export function testStore(id: number, query: string) {
|
||||
return api<any>(`/api/stores/${id}/test`, { method: 'POST', body: JSON.stringify({ query }) });
|
||||
}
|
||||
22
src/client/src/routes/+layout.svelte
Normal file
22
src/client/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<nav class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold text-blue-600 dark:text-blue-400 hover:text-blue-700">
|
||||
Price Hunter
|
||||
</a>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a href="/" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Search</a>
|
||||
<a href="/admin" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Stores</a>
|
||||
<a href="/admin/categories" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Categories</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
81
src/client/src/routes/+page.svelte
Normal file
81
src/client/src/routes/+page.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { getCategories, getGroups } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let query = $state('');
|
||||
let categories = $state([]);
|
||||
let groups = $state([]);
|
||||
let selectedCategory = $state('');
|
||||
let selectedGroup = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
[categories, groups] = await Promise.all([getCategories(), getGroups()]);
|
||||
});
|
||||
|
||||
function handleSearch(e) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
const params = new URLSearchParams({ q: query.trim() });
|
||||
if (selectedCategory) params.set('category', selectedCategory);
|
||||
if (selectedGroup) params.set('group', selectedGroup);
|
||||
|
||||
goto(`/results?${params}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center min-h-[80vh] px-4">
|
||||
<h1 class="text-5xl font-bold text-gray-900 dark:text-white mb-2">Price Hunter</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-8">Search across all your stores at once</p>
|
||||
|
||||
<form onsubmit={handleSearch} class="w-full max-w-2xl">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Search for a product..."
|
||||
class="w-full px-6 py-4 text-lg border-2 border-gray-200 dark:border-gray-700 rounded-full
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-white
|
||||
focus:border-blue-500 focus:outline-none shadow-sm hover:shadow-md transition-shadow"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700 text-white
|
||||
px-6 py-2.5 rounded-full font-medium transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if categories.length > 0 || groups.length > 0}
|
||||
<div class="flex flex-wrap gap-2 mt-4 justify-center">
|
||||
{#if categories.length > 0}
|
||||
<select
|
||||
bind:value={selectedCategory}
|
||||
class="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg
|
||||
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{#each categories as cat}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
{#if groups.length > 0}
|
||||
<select
|
||||
bind:value={selectedGroup}
|
||||
class="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg
|
||||
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">All groups</option>
|
||||
{#each groups as group}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
101
src/client/src/routes/admin/+page.svelte
Normal file
101
src/client/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script>
|
||||
import { getStores, toggleStore, deleteStore } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let stores = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
stores = await getStores();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function handleToggle(id) {
|
||||
const updated = await toggleStore(id);
|
||||
stores = stores.map((s) => (s.id === id ? { ...s, ...updated } : s));
|
||||
}
|
||||
|
||||
async function handleDelete(id, name) {
|
||||
if (!confirm(`Delete store "${name}"? This cannot be undone.`)) return;
|
||||
await deleteStore(id);
|
||||
stores = stores.filter((s) => s.id !== id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Store Management</h1>
|
||||
<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>
|
||||
|
||||
{#if loading}
|
||||
<div class="animate-pulse space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="bg-gray-200 dark:bg-gray-800 h-16 rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if stores.length === 0}
|
||||
<div class="text-center py-20 text-gray-500 dark:text-gray-400">
|
||||
<p class="text-lg mb-2">No stores configured yet</p>
|
||||
<a href="/admin/stores/new" class="text-blue-600 hover:underline">Add your first store</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-800 text-left text-sm text-gray-600 dark:text-gray-400">
|
||||
<th class="px-4 py-3 font-medium">Name</th>
|
||||
<th class="px-4 py-3 font-medium">URL</th>
|
||||
<th class="px-4 py-3 font-medium">Category</th>
|
||||
<th class="px-4 py-3 font-medium text-center">Enabled</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each stores as store}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{store.name}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
|
||||
{store.base_url}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if store.category_name}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full text-white"
|
||||
style="background-color: {store.category_color}"
|
||||
>
|
||||
{store.category_name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button
|
||||
onclick={() => handleToggle(store.id)}
|
||||
class="w-10 h-5 rounded-full transition-colors {store.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'} relative"
|
||||
>
|
||||
<span class="absolute top-0.5 {store.enabled ? 'right-0.5' : 'left-0.5'} w-4 h-4 bg-white rounded-full shadow transition-all"></span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<a href="/admin/stores/{store.id}" class="text-sm text-blue-600 hover:underline">Edit</a>
|
||||
<a href="/admin/stores/{store.id}/test" class="text-sm text-green-600 hover:underline">Test</a>
|
||||
<button onclick={() => handleDelete(store.id, store.name)} class="text-sm text-red-600 hover:underline">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
155
src/client/src/routes/admin/categories/+page.svelte
Normal file
155
src/client/src/routes/admin/categories/+page.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script>
|
||||
import {
|
||||
getCategories, createCategory, updateCategory, deleteCategory,
|
||||
getGroups, createGroup, updateGroupApi, deleteGroup, setGroupMembersApi, getStores,
|
||||
} from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let categories = $state([]);
|
||||
let groups = $state([]);
|
||||
let stores = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let newCatName = $state('');
|
||||
let newCatColor = $state('#6B7280');
|
||||
let newGroupName = $state('');
|
||||
let editingCat = $state(null);
|
||||
let editingGroup = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
[categories, groups, stores] = await Promise.all([getCategories(), getGroups(), getStores()]);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function handleAddCategory() {
|
||||
if (!newCatName.trim()) return;
|
||||
const cat = await createCategory(newCatName.trim(), newCatColor);
|
||||
categories = [...categories, cat];
|
||||
newCatName = '';
|
||||
newCatColor = '#6B7280';
|
||||
}
|
||||
|
||||
async function handleUpdateCategory(id) {
|
||||
if (!editingCat) return;
|
||||
await updateCategory(id, { name: editingCat.name, color: editingCat.color });
|
||||
categories = categories.map((c) => (c.id === id ? { ...c, ...editingCat } : c));
|
||||
editingCat = null;
|
||||
}
|
||||
|
||||
async function handleDeleteCategory(id, name) {
|
||||
if (!confirm(`Delete category "${name}"?`)) return;
|
||||
await deleteCategory(id);
|
||||
categories = categories.filter((c) => c.id !== id);
|
||||
}
|
||||
|
||||
async function handleAddGroup() {
|
||||
if (!newGroupName.trim()) return;
|
||||
const group = await createGroup(newGroupName.trim());
|
||||
groups = [...groups, group];
|
||||
newGroupName = '';
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(id, name) {
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
await deleteGroup(id);
|
||||
groups = groups.filter((g) => g.id !== id);
|
||||
}
|
||||
|
||||
async function handleToggleGroupMember(groupId, storeId) {
|
||||
const group = groups.find((g) => g.id === groupId);
|
||||
if (!group) return;
|
||||
|
||||
const ids = group.store_ids.includes(storeId)
|
||||
? group.store_ids.filter((id) => id !== storeId)
|
||||
: [...group.store_ids, storeId];
|
||||
|
||||
await setGroupMembersApi(groupId, ids);
|
||||
groups = groups.map((g) => (g.id === groupId ? { ...g, store_ids: ids } : g));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-5xl mx-auto px-4 py-6">
|
||||
{#if loading}
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Categories -->
|
||||
<section class="mb-10">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Categories</h1>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input type="text" bind:value={newCatName} placeholder="Category name"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
<input type="color" bind:value={newCatColor}
|
||||
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer" />
|
||||
<button onclick={handleAddCategory}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Add</button>
|
||||
</div>
|
||||
|
||||
{#if categories.length === 0}
|
||||
<p class="text-gray-500 dark:text-gray-400">No categories yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#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">
|
||||
{#if editingCat?.id === cat.id}
|
||||
<input type="color" bind:value={editingCat.color} class="w-8 h-8 rounded cursor-pointer" />
|
||||
<input type="text" bind:value={editingCat.name}
|
||||
class="flex-1 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
<button onclick={() => handleUpdateCategory(cat.id)} class="text-sm text-blue-600 hover:underline">Save</button>
|
||||
<button onclick={() => editingCat = null} class="text-sm text-gray-500 hover:underline">Cancel</button>
|
||||
{:else}
|
||||
<span class="w-4 h-4 rounded-full" style="background-color: {cat.color}"></span>
|
||||
<span class="flex-1 text-gray-900 dark:text-white">{cat.name}</span>
|
||||
<button onclick={() => editingCat = { id: cat.id, name: cat.name, color: cat.color }} class="text-sm text-blue-600 hover:underline">Edit</button>
|
||||
<button onclick={() => handleDeleteCategory(cat.id, cat.name)} class="text-sm text-red-600 hover:underline">Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Groups -->
|
||||
<section>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Custom Groups</h1>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input type="text" bind:value={newGroupName} placeholder="Group name"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
<button onclick={handleAddGroup}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Add</button>
|
||||
</div>
|
||||
|
||||
{#if groups.length === 0}
|
||||
<p class="text-gray-500 dark:text-gray-400">No groups yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#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="flex items-center justify-between mb-3">
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{group.name}</h3>
|
||||
<button onclick={() => handleDeleteGroup(group.id, group.name)} class="text-sm text-red-600 hover:underline">Delete</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each stores as store}
|
||||
<button
|
||||
onclick={() => handleToggleGroupMember(group.id, store.id)}
|
||||
class="text-xs px-3 py-1 rounded-full border transition-colors
|
||||
{group.store_ids.includes(store.id)
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-gray-300'}"
|
||||
>
|
||||
{store.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
200
src/client/src/routes/admin/stores/[id]/+page.svelte
Normal file
200
src/client/src/routes/admin/stores/[id]/+page.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getStore, updateStore, getCategories } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let categories = $state([]);
|
||||
let error = $state('');
|
||||
let saving = $state(false);
|
||||
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: '',
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const id = $page.params.id;
|
||||
const [store, cats] = await Promise.all([getStore(Number(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 || '',
|
||||
};
|
||||
loading = false;
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
await updateStore(Number($page.params.id), data);
|
||||
goto('/admin');
|
||||
} catch (err) {
|
||||
error = err.message || 'Failed to update store';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Edit Store</h1>
|
||||
<a href="/admin/stores/{$page.params.id}/test"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
|
||||
Test Store
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-800 h-60 rounded-lg"></div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg mb-4">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Store Name *</label>
|
||||
<input type="text" bind:value={form.name} required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
||||
<select bind:value={form.category_id}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
|
||||
<option value="">No category</option>
|
||||
{#each categories as cat}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base URL *</label>
|
||||
<input type="url" bind:value={form.base_url} required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search URL *</label>
|
||||
<input type="text" bind:value={form.search_url} required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
<p class="text-xs text-gray-500 mt-1">Use {'{query}'} as placeholder</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
|
||||
<input type="text" bind:value={form.currency}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">CSS Selectors</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Container *</label>
|
||||
<input type="text" bind:value={form.sel_container} required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Name *</label>
|
||||
<input type="text" bind:value={form.sel_name} required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Price *</label>
|
||||
<input type="text" bind:value={form.sel_price} required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Link *</label>
|
||||
<input type="text" bind:value={form.sel_link} required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image</label>
|
||||
<input type="text" bind:value={form.sel_image}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Advanced Settings</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rate Limit</label>
|
||||
<input type="number" bind:value={form.rate_limit} min="1" max="10"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Agent</label>
|
||||
<input type="text" bind:value={form.user_agent}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Proxy URL</label>
|
||||
<input type="text" bind:value={form.proxy_url}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Extra Headers (JSON)</label>
|
||||
<input type="text" bind:value={form.headers_json}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" disabled={saving}
|
||||
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2.5 rounded-lg font-medium">
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<a href="/admin" class="px-6 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
200
src/client/src/routes/admin/stores/[id]/test/+page.svelte
Normal file
200
src/client/src/routes/admin/stores/[id]/test/+page.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { getStore, testStore } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let store = $state(null);
|
||||
let query = $state('');
|
||||
let result = $state(null);
|
||||
let testing = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
store = await getStore(Number($page.params.id));
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function handleTest(e) {
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
{#if loading}
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-gray-200 dark:bg-gray-800 h-8 w-48 rounded mb-4"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-800 h-12 rounded mb-4"></div>
|
||||
</div>
|
||||
{:else if store}
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/admin/stores/{store.id}" class="text-gray-400 hover:text-gray-600">←</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Test: {store.name}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{store.base_url}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Stats -->
|
||||
{#if store.health}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-white">{store.health.total}</div>
|
||||
<div class="text-xs text-gray-500">Total Scrapes</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="text-2xl font-bold text-green-600">{store.health.successful}</div>
|
||||
<div class="text-xs text-gray-500">Successful</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="text-2xl font-bold text-red-600">{store.health.failed}</div>
|
||||
<div class="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-white">{store.health.avg_duration_ms}ms</div>
|
||||
<div class="text-xs text-gray-500">Avg Duration</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Test Form -->
|
||||
<form onsubmit={handleTest} class="mb-6">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Enter a test search query..."
|
||||
class="flex-1 px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={testing}
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white px-6 py-2.5 rounded-lg font-medium"
|
||||
>
|
||||
{testing ? 'Testing...' : 'Run Test'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Test Results -->
|
||||
{#if result}
|
||||
<div class="space-y-6">
|
||||
<!-- Status Banner -->
|
||||
<div class="p-4 rounded-lg {result.success ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' : 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400'}">
|
||||
<div class="font-medium">
|
||||
{result.success ? `Found ${result.itemsFound} products` : 'Test failed'}
|
||||
</div>
|
||||
{#if result.searchUrl}
|
||||
<div class="text-sm mt-1 break-all opacity-80">URL: {result.searchUrl}</div>
|
||||
{/if}
|
||||
{#if result.duration}
|
||||
<div class="text-sm mt-1 opacity-80">Duration: {result.duration}ms</div>
|
||||
{/if}
|
||||
{#if result.error}
|
||||
<div class="text-sm mt-1">{result.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if result.success}
|
||||
<!-- Parsed Results -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Parsed Products ({result.parsedProducts?.length || 0})</h2>
|
||||
{#if result.parsedProducts?.length > 0}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-800 text-left">
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Name</th>
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Price</th>
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Raw Price</th>
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each result.parsedProducts as product}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-800">
|
||||
<td class="px-4 py-2 text-gray-900 dark:text-white max-w-xs truncate">{product.name}</td>
|
||||
<td class="px-4 py-2 font-medium text-blue-600 dark:text-blue-400">{formatPrice(product.price, product.currency)}</td>
|
||||
<td class="px-4 py-2 text-gray-500 font-mono text-xs">{product.priceText}</td>
|
||||
<td class="px-4 py-2">
|
||||
<a href={product.url} target="_blank" rel="noopener" class="text-blue-600 hover:underline truncate block max-w-xs">
|
||||
{product.url}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-500 dark:text-gray-400">No products parsed. Check your CSS selectors.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Raw HTML Preview -->
|
||||
<details class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||
<summary class="px-4 py-3 cursor-pointer font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
Raw HTML Preview ({result.rawHtmlLength?.toLocaleString()} bytes)
|
||||
</summary>
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-800">
|
||||
<pre class="text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap max-h-96 overflow-y-auto">{result.rawHtmlPreview}</pre>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<!-- Recent Logs -->
|
||||
{#if result.recentLogs?.length > 0}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Recent Scrape Logs</h2>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-800 text-left">
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Status</th>
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Query</th>
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Results</th>
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Duration</th>
|
||||
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each result.recentLogs as log}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-800">
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-block w-2 h-2 rounded-full {log.success ? 'bg-green-500' : 'bg-red-500'}"></span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{log.query}</td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{log.result_count}</td>
|
||||
<td class="px-4 py-2 text-gray-500">{log.duration_ms}ms</td>
|
||||
<td class="px-4 py-2 text-gray-500 text-xs">{log.scraped_at}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
174
src/client/src/routes/admin/stores/new/+page.svelte
Normal file
174
src/client/src/routes/admin/stores/new/+page.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { createStore, getCategories } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let categories = $state([]);
|
||||
let error = $state('');
|
||||
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: '',
|
||||
});
|
||||
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Add New Store</h1>
|
||||
|
||||
{#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>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Store Name *</label>
|
||||
<input type="text" bind:value={form.name} required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
||||
<select bind:value={form.category_id}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
|
||||
<option value="">No category</option>
|
||||
{#each categories as cat}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base URL *</label>
|
||||
<input type="url" bind:value={form.base_url} required placeholder="https://store.example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search URL *</label>
|
||||
<input type="text" bind:value={form.search_url} required placeholder={'https://store.example.com/search?q={query}'}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
<p class="text-xs text-gray-500 mt-1">Use {'{query}'} as placeholder for the search term</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
|
||||
<input type="text" bind:value={form.currency} placeholder="EUR"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CSS Selectors -->
|
||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">CSS Selectors</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Define how to extract product data from the store's search results page.</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Container *</label>
|
||||
<input type="text" bind:value={form.sel_container} required placeholder=".product-card"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<p class="text-xs text-gray-500 mt-1">Selector for each product listing item</p>
|
||||
</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 placeholder=".product-title"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Price *</label>
|
||||
<input type="text" bind:value={form.sel_price} required placeholder=".product-price"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Link *</label>
|
||||
<input type="text" bind:value={form.sel_link} required placeholder="a"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image (optional)</label>
|
||||
<input type="text" bind:value={form.sel_image} placeholder="img"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Advanced -->
|
||||
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Advanced Settings</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rate Limit (req/sec)</label>
|
||||
<input type="number" bind:value={form.rate_limit} min="1" max="10"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Agent</label>
|
||||
<input type="text" bind:value={form.user_agent} placeholder="Default browser UA"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Proxy URL</label>
|
||||
<input type="text" bind:value={form.proxy_url} placeholder="http://user:pass@host:port"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Extra Headers (JSON)</label>
|
||||
<input type="text" bind:value={form.headers_json} placeholder={'{"X-Custom": "value"}'}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" disabled={saving}
|
||||
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2.5 rounded-lg font-medium transition-colors">
|
||||
{saving ? 'Creating...' : 'Create Store'}
|
||||
</button>
|
||||
<a href="/admin" class="px-6 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
186
src/client/src/routes/results/+page.svelte
Normal file
186
src/client/src/routes/results/+page.svelte
Normal file
@@ -0,0 +1,186 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { searchProducts } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let query = $state('');
|
||||
let results = $state([]);
|
||||
let meta = $state(null);
|
||||
let loading = $state(true);
|
||||
let sortBy = $state('price');
|
||||
|
||||
onMount(async () => {
|
||||
const params = $page.url.searchParams;
|
||||
query = params.get('q') || '';
|
||||
|
||||
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, {
|
||||
category: params.get('category') || undefined,
|
||||
group: params.get('group') || undefined,
|
||||
stores: params.get('stores') || undefined,
|
||||
});
|
||||
results = data.results;
|
||||
meta = data.meta;
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(e) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
goto(`/results?q=${encodeURIComponent(query.trim())}`);
|
||||
doSearch();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
<!-- Search bar -->
|
||||
<form onsubmit={handleSearch} class="mb-6">
|
||||
<div class="relative max-w-2xl">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Search for a product..."
|
||||
class="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700
|
||||
text-white px-4 py-1.5 rounded-full text-sm">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Meta info & sort -->
|
||||
{#if meta}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms
|
||||
</p>
|
||||
<select
|
||||
bind:value={sortBy}
|
||||
class="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5
|
||||
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="price">Price: Low to High</option>
|
||||
<option value="price-desc">Price: High to Low</option>
|
||||
<option value="name">Name</option>
|
||||
<option value="store">Store</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Errors -->
|
||||
{#if meta?.errors?.length > 0}
|
||||
<div class="mb-4 space-y-1">
|
||||
{#each meta.errors as err}
|
||||
<div class="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-1.5 rounded">
|
||||
{err.storeName}: {err.error}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading -->
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{#each Array(8) as _}
|
||||
<div class="animate-pulse bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded mb-3"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-4 rounded w-3/4 mb-2"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-6 rounded w-1/3"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if sortedResults().length === 0}
|
||||
<div class="text-center py-20 text-gray-500 dark:text-gray-400">
|
||||
<p class="text-lg">No results found for "{query}"</p>
|
||||
<p class="text-sm mt-2">Try a different search term or check your store configurations.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Results grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{#each sortedResults() as product}
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800
|
||||
hover:shadow-lg transition-shadow overflow-hidden group"
|
||||
>
|
||||
{#if product.image}
|
||||
<div class="h-40 bg-gray-100 dark:bg-gray-800 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
class="max-h-full max-w-full object-contain p-2 group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-40 bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<span class="text-gray-400 text-4xl">?</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white line-clamp-2 mb-2">
|
||||
{product.name}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||
{formatPrice(product.price, product.currency)}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
||||
{product.storeName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
16
src/client/svelte.config.js
Normal file
16
src/client/svelte.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: false,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
src/client/tailwind.config.js
Normal file
9
src/client/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
darkMode: 'media',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
14
src/client/vite.config.ts
Normal file
14
src/client/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user