mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-23 07:08:12 +02:00
feat: Add multi-site product search system with SearXNG integration
This commit adds a comprehensive product search feature that allows users to search for products across multiple configured e-commerce sites. Backend changes: - Add SearXNG service to docker-compose for meta-search capability - Add SearchSite model and Alembic migration with default sites - Create searxng_service.py for search queries across sites - Create light_scraper_service.py for fast HTTP-based extraction - Create search_service.py orchestrator combining SearXNG + HTTP + Browserless - Add API endpoints for search (/api/search) with SSE streaming - Add CRUD endpoints for search sites (/api/search-sites) Frontend changes: - Add new /search page with real-time results display - Create SearchResultCard component for product display - Add Progress, Checkbox, and Badge UI components - Update Settings page with search sites management section - Add navigation link to search page - Create new modern SVG logo with price-flow gradient design - Update favicon to SVG format The search system uses a hybrid approach: 1. SearXNG finds product URLs across configured sites 2. Light HTTP scraper attempts fast extraction 3. Falls back to Browserless for JavaScript-heavy sites 4. Results stream progressively via SSE
This commit is contained in:
26 files changed
+1831
-10
No files matched your search
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import Dashboard from '@/pages/Dashboard'
|
||||
import Search from '@/pages/Search'
|
||||
import Settings from '@/pages/Settings'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
@@ -15,6 +16,7 @@ export default function App() {
|
||||
<Layout theme={theme} toggleTheme={toggleTheme}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LayoutDashboard, Settings, Moon, Sun, Menu } from 'lucide-react';
|
||||
import { LayoutDashboard, Search, Settings, Moon, Sun, Menu } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -13,6 +13,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
|
||||
const navItems = [
|
||||
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
|
||||
{ icon: Search, label: t('nav.search') || 'Recherche', path: '/search' },
|
||||
{ icon: Settings, label: t('nav.settings'), path: '/settings' },
|
||||
];
|
||||
|
||||
@@ -23,7 +24,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-14 items-center border-b px-6">
|
||||
<Link to="/" className="flex items-center gap-2 font-semibold">
|
||||
<img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8" />
|
||||
<img src="/logo.svg" alt="PriceFlow Logo" className="h-8 w-8 rounded-lg" />
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
PriceFlow
|
||||
</span>
|
||||
@@ -80,9 +81,9 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-14 items-center border-b px-6">
|
||||
<img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8 mr-2" />
|
||||
<img src="/logo.svg" alt="PriceFlow Logo" className="h-8 w-8 mr-2 rounded-lg" />
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
Pricecious
|
||||
PriceFlow
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
@@ -125,7 +126,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Link to="/" className="flex items-center gap-2 font-semibold">
|
||||
<img src="/logo.png" alt="PriceFlow Logo" className="h-6 w-6" />
|
||||
<img src="/logo.svg" alt="PriceFlow Logo" className="h-6 w-6 rounded" />
|
||||
PriceFlow
|
||||
</Link>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { ExternalLink, Plus, Package, PackageX } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export function SearchResultCard({ result, onAddToMonitoring }) {
|
||||
const formatPrice = (price) => {
|
||||
if (price === null || price === undefined) return 'N/A';
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: result.currency || 'EUR',
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const truncateTitle = (title, maxLength = 60) => {
|
||||
if (!title) return 'Produit sans nom';
|
||||
if (title.length <= maxLength) return title;
|
||||
return title.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-0">
|
||||
{/* Image */}
|
||||
<div className="aspect-square bg-muted flex items-center justify-center">
|
||||
{result.image_url ? (
|
||||
<img
|
||||
src={result.image_url}
|
||||
alt={result.title}
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`w-full h-full items-center justify-center text-muted-foreground ${result.image_url ? 'hidden' : 'flex'}`}
|
||||
>
|
||||
<Package className="h-16 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Title */}
|
||||
<h3 className="font-medium text-sm leading-tight line-clamp-2" title={result.title}>
|
||||
{truncateTitle(result.title)}
|
||||
</h3>
|
||||
|
||||
{/* Price & Stock */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{formatPrice(result.price)}
|
||||
</span>
|
||||
{result.in_stock !== null && (
|
||||
<Badge
|
||||
variant={result.in_stock ? 'default' : 'destructive'}
|
||||
className="text-xs"
|
||||
>
|
||||
{result.in_stock ? (
|
||||
<>
|
||||
<Package className="h-3 w-3 mr-1" />
|
||||
En stock
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageX className="h-3 w-3 mr-1" />
|
||||
Rupture
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site */}
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<span className="truncate">{result.site_name}</span>
|
||||
{result.confidence && (
|
||||
<span className="ml-auto text-xs opacity-50">
|
||||
{Math.round(result.confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onAddToMonitoring}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Suivre
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<a href={result.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-transparent bg-green-500 text-white shadow hover:bg-green-500/80",
|
||||
warning:
|
||||
"border-transparent bg-yellow-500 text-white shadow hover:bg-yellow-500/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"search": "Recherche",
|
||||
"settings": "Paramètres",
|
||||
"lightMode": "Mode clair",
|
||||
"darkMode": "Mode sombre"
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search as SearchIcon, Loader2, Plus, ExternalLink, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { SearchResultCard } from '@/components/search/SearchResultCard';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [sites, setSites] = useState([]);
|
||||
const [selectedSites, setSelectedSites] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [progress, setProgress] = useState(null);
|
||||
const [results, setResults] = useState([]);
|
||||
const eventSourceRef = useRef(null);
|
||||
|
||||
// Charger les sites au démarrage
|
||||
useEffect(() => {
|
||||
loadSites();
|
||||
}, []);
|
||||
|
||||
const loadSites = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/search-sites`);
|
||||
setSites(response.data);
|
||||
// Sélectionner tous les sites actifs par défaut
|
||||
setSelectedSites(
|
||||
response.data
|
||||
.filter(site => site.is_active)
|
||||
.map(site => site.id)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error loading sites:', error);
|
||||
toast.error('Erreur lors du chargement des sites');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSite = (siteId) => {
|
||||
setSelectedSites(prev =>
|
||||
prev.includes(siteId)
|
||||
? prev.filter(id => id !== siteId)
|
||||
: [...prev, siteId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim() || selectedSites.length === 0) {
|
||||
toast.error('Veuillez entrer une recherche et sélectionner au moins un site');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermer l'EventSource précédent si existant
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setResults([]);
|
||||
setProgress({ status: 'searching', total: 0, completed: 0 });
|
||||
|
||||
// Construire l'URL avec les paramètres
|
||||
const params = new URLSearchParams({
|
||||
q: query.trim(),
|
||||
sites: selectedSites.join(','),
|
||||
max_results: 20,
|
||||
});
|
||||
|
||||
// Créer l'EventSource pour SSE
|
||||
const eventSource = new EventSource(`${API_URL}/search?${params}`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.addEventListener('progress', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setProgress(data);
|
||||
setResults(data.results || []);
|
||||
|
||||
if (data.status === 'completed' || data.status === 'error') {
|
||||
setIsSearching(false);
|
||||
eventSource.close();
|
||||
|
||||
if (data.status === 'completed') {
|
||||
toast.success(`${data.results?.length || 0} produits trouvés`);
|
||||
} else if (data.message) {
|
||||
toast.error(data.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing SSE data:', error);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setIsSearching(false);
|
||||
eventSource.close();
|
||||
toast.error('Erreur de connexion au serveur');
|
||||
};
|
||||
};
|
||||
|
||||
const handleAddToMonitoring = async (result) => {
|
||||
try {
|
||||
await axios.post(`${API_URL}/items`, {
|
||||
url: result.url,
|
||||
name: result.title,
|
||||
target_price: null,
|
||||
check_interval_minutes: 60,
|
||||
});
|
||||
toast.success(`"${result.title}" ajouté au monitoring`);
|
||||
} catch (error) {
|
||||
console.error('Error adding item:', error);
|
||||
toast.error("Erreur lors de l'ajout au monitoring");
|
||||
}
|
||||
};
|
||||
|
||||
const progressPercent = progress
|
||||
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Recherche de produits</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Recherchez des produits sur plusieurs sites et ajoutez-les au monitoring
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/settings">
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Gérer les sites
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Rechercher un produit..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
disabled={isSearching}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isSearching || !query.trim()}>
|
||||
{isSearching ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Recherche...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SearchIcon className="h-4 w-4 mr-2" />
|
||||
Rechercher
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Site Selection */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Sites de recherche :</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{sites.filter(s => s.is_active).map(site => (
|
||||
<label
|
||||
key={site.id}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedSites.includes(site.id)
|
||||
? 'bg-primary/10 border-primary'
|
||||
: 'bg-background border-border hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedSites.includes(site.id)}
|
||||
onCheckedChange={() => toggleSite(site.id)}
|
||||
/>
|
||||
<span className="text-sm">{site.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{sites.filter(s => s.is_active).length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucun site configuré.{' '}
|
||||
<Link to="/settings" className="text-primary underline">
|
||||
Ajouter des sites
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{progress && isSearching && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{progress.message || 'Recherche en cours...'}</span>
|
||||
<span>{progress.completed}/{progress.total}</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{results.length} produit{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{results.map((result, index) => (
|
||||
<SearchResultCard
|
||||
key={`${result.url}-${index}`}
|
||||
result={result}
|
||||
onAddToMonitoring={() => handleAddToMonitoring(result)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isSearching && results.length === 0 && progress?.status === 'completed' && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<SearchIcon className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Aucun produit trouvé</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Essayez avec d'autres termes de recherche ou sélectionnez plus de sites
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles } from 'lucide-react';
|
||||
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles, Globe, Plus } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -54,6 +54,11 @@ export default function Settings() {
|
||||
const [editingProfileId, setEditingProfileId] = useState(null);
|
||||
const [showAdvancedAI, setShowAdvancedAI] = useState(false);
|
||||
|
||||
// Search Sites state
|
||||
const [searchSites, setSearchSites] = useState([]);
|
||||
const [newSite, setNewSite] = useState({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
|
||||
const [editingSiteId, setEditingSiteId] = useState(null);
|
||||
|
||||
// OpenRouter state
|
||||
const [openrouterModels, setOpenrouterModels] = useState([]);
|
||||
const [openrouterLoading, setOpenrouterLoading] = useState(false);
|
||||
@@ -108,14 +113,16 @@ export default function Settings() {
|
||||
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
const [profilesRes, settingsRes, jobRes] = await Promise.all([
|
||||
const [profilesRes, settingsRes, jobRes, sitesRes] = await Promise.all([
|
||||
axios.get(`${API_URL}/notification-profiles`),
|
||||
axios.get(`${API_URL}/settings`),
|
||||
axios.get(`${API_URL}/jobs/config`)
|
||||
axios.get(`${API_URL}/jobs/config`),
|
||||
axios.get(`${API_URL}/search-sites`)
|
||||
]);
|
||||
|
||||
setProfiles(profilesRes.data);
|
||||
setJobConfig(jobRes.data);
|
||||
setSearchSites(sitesRes.data);
|
||||
|
||||
const settingsMap = {};
|
||||
settingsRes.data.forEach(s => settingsMap[s.key] = s.value);
|
||||
@@ -233,6 +240,66 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
// Search Sites functions
|
||||
const handleSiteSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingSiteId) {
|
||||
await axios.put(`${API_URL}/search-sites/${editingSiteId}`, newSite);
|
||||
toast.success('Site mis à jour');
|
||||
} else {
|
||||
await axios.post(`${API_URL}/search-sites`, newSite);
|
||||
toast.success('Site ajouté');
|
||||
}
|
||||
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
|
||||
setEditingSiteId(null);
|
||||
fetchAll();
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'opération sur le site');
|
||||
}
|
||||
};
|
||||
|
||||
const editSite = (site) => {
|
||||
setNewSite({
|
||||
name: site.name,
|
||||
domain: site.domain,
|
||||
category: site.category || '',
|
||||
is_active: site.is_active,
|
||||
priority: site.priority,
|
||||
requires_js: site.requires_js
|
||||
});
|
||||
setEditingSiteId(site.id);
|
||||
};
|
||||
|
||||
const cancelSiteEdit = () => {
|
||||
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
|
||||
setEditingSiteId(null);
|
||||
};
|
||||
|
||||
const deleteSite = async (id) => {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer ce site ?')) {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/search-sites/${id}`);
|
||||
toast.success('Site supprimé');
|
||||
if (editingSiteId === id) {
|
||||
cancelSiteEdit();
|
||||
}
|
||||
fetchAll();
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSiteActive = async (site) => {
|
||||
try {
|
||||
await axios.put(`${API_URL}/search-sites/${site.id}`, { is_active: !site.is_active });
|
||||
fetchAll();
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in duration-500 max-w-5xl mx-auto pb-10">
|
||||
<div>
|
||||
@@ -533,6 +600,114 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Search Sites Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Globe className="h-5 w-5" />Sites de recherche</CardTitle>
|
||||
<CardDescription>Configurez les sites sur lesquels rechercher des produits.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<form onSubmit={handleSiteSubmit} className="space-y-4 border-b pb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">{editingSiteId ? 'Modifier le site' : 'Ajouter un site'}</h4>
|
||||
{editingSiteId && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={cancelSiteEdit}>Annuler</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nom du site</Label>
|
||||
<Input required value={newSite.name} onChange={(e) => setNewSite({ ...newSite, name: e.target.value })} placeholder="ex: Amazon France" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Domaine</Label>
|
||||
<Input required value={newSite.domain} onChange={(e) => setNewSite({ ...newSite, domain: e.target.value })} placeholder="ex: amazon.fr" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Catégorie</Label>
|
||||
<Input value={newSite.category} onChange={(e) => setNewSite({ ...newSite, category: e.target.value })} placeholder="ex: Électronique, Déco, Généraliste" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Priorité</Label>
|
||||
<Input type="number" min="0" value={newSite.priority} onChange={(e) => setNewSite({ ...newSite, priority: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch checked={newSite.is_active} onCheckedChange={(checked) => setNewSite({ ...newSite, is_active: checked })} />
|
||||
<Label>Actif</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch checked={newSite.requires_js} onCheckedChange={(checked) => setNewSite({ ...newSite, requires_js: checked })} />
|
||||
<Label>Nécessite JavaScript</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{editingSiteId ? 'Mettre à jour' : 'Ajouter le site'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">Sites configurés ({searchSites.length})</h4>
|
||||
{searchSites.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucun site configuré.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{searchSites.map(site => (
|
||||
<div key={site.id} className={cn(
|
||||
"relative group overflow-hidden rounded-xl border bg-card text-card-foreground shadow transition-all hover:shadow-md",
|
||||
editingSiteId === site.id && "ring-2 ring-primary",
|
||||
!site.is_active && "opacity-60"
|
||||
)}>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
|
||||
{site.name}
|
||||
{site.is_active ? (
|
||||
<span className="px-2 py-0.5 bg-green-500/10 text-green-700 dark:text-green-400 rounded text-xs">Actif</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 bg-red-500/10 text-red-700 dark:text-red-400 rounded text-xs">Inactif</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{site.domain}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => editSite(site)}>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => deleteSite(site.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{site.category && (
|
||||
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-secondary text-secondary-foreground">
|
||||
{site.category}
|
||||
</span>
|
||||
)}
|
||||
{site.requires_js && (
|
||||
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-blue-500/10 text-blue-700 dark:text-blue-400">
|
||||
JavaScript
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Switch checked={site.is_active} onCheckedChange={() => toggleSiteActive(site)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification Profiles */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
Reference in new issue
Block a user