Merge pull request #38 from R0m1k3/antigravity

feat: Add initial database migration and UI label component, alongsid…
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-23 14:38:07 +01:00
commit 7dd153c604
17 files changed
+859 -40

No files matched your search

@@ -0,0 +1,26 @@
"""add_category_to_items
Revision ID: add_category_field
Revises: add_is_available
Create Date: 2025-11-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_category_field'
down_revision = 'add_is_available'
branch_labels = None
depends_on = None
def upgrade():
# Add category column to items table
op.add_column('items', sa.Column('category', sa.String(255), nullable=True))
def downgrade():
# Remove category column from items table
op.drop_column('items', 'category')
+26
View File
@@ -0,0 +1,26 @@
"""add_is_available_to_items
Revision ID: add_is_available
Revises:
Create Date: 2025-11-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_is_available'
down_revision = None # Update this if there are previous migrations
branch_labels = None
depends_on = None
def upgrade():
# Add is_available column to items table
op.add_column('items', sa.Column('is_available', sa.Boolean(), nullable=True, server_default='true'))
def downgrade():
# Remove is_available column from items table
op.drop_column('items', 'is_available')
+2
View File
@@ -47,6 +47,8 @@ class Item(Base):
# New fields
current_price: float | None = Column(Float, nullable=True) # type: ignore
in_stock: bool | None = Column(Boolean, nullable=True) # type: ignore
is_available: bool | None = Column(Boolean, nullable=True, default=True) # type: ignore # Product still exists
category: str | None = Column(String, nullable=True) # type: ignore # User-defined category
tags: str | None = Column(String, nullable=True) # type: ignore
description: str | None = Column(String, nullable=True) # type: ignore
+27 -3
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app import database, schemas
from app import database, models, schemas
from app.limiter import limiter
from app.services.item_service import ItemService
from app.services.scheduler_service import process_item_check
@@ -10,8 +10,12 @@ router = APIRouter(prefix="/items", tags=["items"])
@router.get("", response_model=list[schemas.ItemResponse])
def get_items(db: Session = Depends(database.get_db)):
return ItemService.get_items(db)
def get_items(category: str | None = None, db: Session = Depends(database.get_db)):
"""Get all items, optionally filtered by category"""
items = ItemService.get_items(db)
if category:
items = [item for item in items if item.category == category]
return items
@router.post("", response_model=schemas.ItemResponse)
@@ -29,6 +33,26 @@ def delete_item(item_id: int, db: Session = Depends(database.get_db)):
return ItemService.delete_item(db, item_id)
@router.patch("/{item_id}/category")
def update_item_category(item_id: int, category: str | None, db: Session = Depends(database.get_db)):
"""Update the category of an item"""
item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
item.category = category
db.commit()
db.refresh(item)
return {"message": "Category updated", "category": category}
@router.get("/categories/list")
def get_categories(db: Session = Depends(database.get_db)):
"""Get list of distinct categories used in items"""
categories = db.query(models.Item.category).distinct().filter(models.Item.category.isnot(None)).all()
return [cat[0] for cat in categories if cat[0]]
@router.post("/{item_id}/check")
@limiter.limit("10/minute")
def check_item(
+55 -17
View File
@@ -70,8 +70,10 @@ DEFAULT_SITE_CONFIGS = {
"name": "B&M",
"search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}",
"product_selector": (
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
".products article a, a[href*='/produits/']"
# Cibler uniquement les liens produits, pas les catégories
"a.product-thumbnail[href*='/produits/']:not([href*='/c/']):not([href*='/categorie']), "
".product-miniature a[href*='/produits/']:not([href*='/c/']), "
".js-product-miniature a[href*='/produits/']"
),
"wait_selector": ".products, .product-miniature, #js-product-list, #search_results",
"category": "Discount",
@@ -190,10 +192,12 @@ DEFAULT_SITE_CONFIGS = {
"name": "Cdiscount",
"search_url": "https://www.cdiscount.com/search/10/{query}.html",
"product_selector": (
"a.prdtBImg, .prdtBILDetails a, .product-item a, "
"a[href*='/f-']"
# Sélecteurs spécifiques Cdiscount - produits uniquement
"a.prdtBImg[href*='/f-'][href*='.html'], "
".prdtBILDetails a[href*='/f-'][href*='.html'], "
"a[href*='/f-'][href*='.html']:not([href*='/l-']):not([href*='/rayon'])"
),
"wait_selector": ".prdtBILDetails, .product-list",
"wait_selector": ".prdtBILDetails, .product-list, #lpBloc",
"category": "E-commerce",
"requires_js": True,
"priority": 12,
@@ -203,10 +207,12 @@ DEFAULT_SITE_CONFIGS = {
"name": "Darty",
"search_url": "https://www.darty.com/nav/recherche?text={query}",
"product_selector": (
".product-card a, a[href*='/product'], "
".product-list-item a"
# Cibler les fiches produits, exclure navigation et catégories
"a[href*='/nav/achat/'][href*='.html']:not([href*='/rayon']):not([href*='/promo']), "
".product-card a[href*='/nav/achat/'], "
"a.product-link[href*='/nav/achat/']"
),
"wait_selector": ".product-card, .product-list",
"wait_selector": ".product-card, .product-list, .product-item",
"category": "Électronique",
"requires_js": True,
"priority": 13,
@@ -215,10 +221,12 @@ DEFAULT_SITE_CONFIGS = {
"name": "Boulanger",
"search_url": "https://www.boulanger.com/resultats?tr={query}",
"product_selector": (
".product-list__item a, a[href*='/ref/'], "
".product-card a"
# Cibler uniquement les pages produits avec /ref/
"a[href*='/ref/'][href*='_']:not([href*='/c/']):not([href*='/rayon']), "
".product-list__item a[href*='/ref/'], "
".product-card__link[href*='/ref/']"
),
"wait_selector": ".product-list, .product-card",
"wait_selector": ".product-list, .product-card, .products-list",
"category": "Électronique",
"requires_js": True,
"priority": 14,
@@ -241,8 +249,13 @@ DEFAULT_SITE_CONFIGS = {
"fnac.com": {
"name": "Fnac",
"search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}",
"product_selector": "a.Article-title, .Article-item a.js-minifa-title",
"wait_selector": ".Article-item",
"product_selector": (
# Sélecteurs Fnac pour les produits
"a.Article-title[href*='/a'], "
".Article-item a.js-minifa-title[href*='/a'], "
"a[href*='/a'][href*='.html']:not([href*='/rayon']):not([href*='/univers'])"
),
"wait_selector": ".Article-item, .Article-list, #SearchResultList",
"category": "Culture & Tech",
"requires_js": True,
"priority": 15,
@@ -721,8 +734,9 @@ def _is_navigation_link(href: str) -> bool:
"""Vérifie si un lien est un lien de navigation (pas un produit)"""
nav_patterns = [
# Catégories et navigation
"/category", "/categories", "/categorie",
"/brand", "/brands", "/marque",
"/category", "/categories", "/categorie", "/c/",
"/rayon", "/rayons", "/univers",
"/brand", "/brands", "/marque", "/marques",
"/page/", "/pages/",
"/cart", "/panier", "/basket",
"/checkout", "/commande",
@@ -740,13 +754,24 @@ def _is_navigation_link(href: str) -> bool:
"/application", "/app/", "/apps/",
"/courses", "/course/",
"/ep-", "/endpoint",
# Promotions et offres (patterns communs)
"/promo/", "/promotions/", "/promotion/",
"/offres/", "/offre/",
"/soldes/", "/solde/",
"/bon-plan", "/bons-plans",
"/offres/", "/offre/", "/offre-",
"/deals/", "/deal/",
"/ventes-flash", "/vente-flash",
# Services et pages informatives
"/services/", "/service/",
"/magasin", "/magasins", "/stores/", "/store/",
"/blog", "/actualites", "/news",
"/recettes", "/recipes",
"/conseils", "/tips",
"/guide", "/guides",
# Patterns e-commerce spécifiques
"/l-", "/lp-", "/landing", # Landing pages (Cdiscount, etc.)
"/selection", "/selections",
"/top-", "/best-",
# Amazon spécifique
"/ref=cs_", "/logo", "/nav_", "/gp/help",
"/gp/css", "/gp/redirect", "/hz/",
@@ -763,6 +788,7 @@ def _is_navigation_link(href: str) -> bool:
return any(pattern in href_lower for pattern in nav_patterns)
def _extract_title(element) -> str:
"""Extrait le titre d'un élément"""
# Essayer différentes méthodes
@@ -855,16 +881,28 @@ def _is_non_product_url(url: str) -> bool:
return True
non_product_patterns = [
"/search", "/category", "/categories", "/brand", "/brands",
# Navigation et catégories
"/search", "/category", "/categories", "/categorie", "/c/",
"/rayon", "/rayons", "/univers",
"/brand", "/brands", "/marque", "/marques",
# Pages utilisateur
"/help", "/contact", "/about", "/account", "/cart", "/checkout",
"/login", "/register", "/wishlist", "/compare", "/reviews",
# Pages légales
"/terms", "/privacy", "/faq", "/shipping", "/returns",
# Erreurs
"/503", "/error", "/robot", "/captcha",
# Applications et services
"/application", "/courses", "/app/",
# Landing pages et promotions (patterns spécifiques e-commerce français)
"/l-", "/lp-", "/landing", # Cdiscount landing pages
"/promo/", "/soldes/", "/bon-plan", "/offre-", "/deals/",
"/selection", "/top-", "/best-",
]
return any(pattern in url_lower for pattern in non_product_patterns)
async def health_check() -> bool:
"""Vérifie la connectivité avec Browserless"""
try:
+10 -1
View File
@@ -103,7 +103,7 @@ async def process_item_check(item_id: int):
try:
logger.info(f"Checking item: {item_data['name']} ({item_data['url']})")
screenshot_path, page_text = await ScraperService.scrape_item(
screenshot_path, page_text, is_available = await ScraperService.scrape_item(
item_data["url"],
item_data["selector"],
item_id,
@@ -113,6 +113,15 @@ async def process_item_check(item_id: int):
timeout=config["scraper_timeout"],
)
# Update availability status in database
with database.SessionLocal() as session:
if item := session.query(models.Item).filter(models.Item.id == item_id).first():
item.is_available = is_available
if not is_available:
logger.warning(f"Product {item_id} marked as unavailable")
item.last_error = "Product no longer available (404 or not found)"
session.commit()
if not screenshot_path:
raise Exception("Failed to capture screenshot")
+52 -8
View File
@@ -88,10 +88,10 @@ class ScraperService:
scroll_pixels: int = 350,
text_length: int = 0,
timeout: int = 90000,
) -> tuple[str | None, str]:
) -> tuple[str | None, str, bool]:
"""
Scrapes the given URL using Browserless and Playwright.
Returns a tuple: (screenshot_path, page_text)
Returns a tuple: (screenshot_path, page_text, is_available)
Args:
url: Target URL to scrape
@@ -139,7 +139,7 @@ class ScraperService:
logger.warning(f"Tentative {attempt + 1} échouée pour {url}")
logger.error(f"Toutes les tentatives ont échoué pour {url}")
return None, ""
return None, "", False # Product unavailable if all retries failed
@staticmethod
async def _do_scrape( # noqa: PLR0913, PLR0912, PLR0915
@@ -150,12 +150,14 @@ class ScraperService:
scroll_pixels: int = 350,
text_length: int = 0,
timeout: int = 90000,
) -> tuple[str | None, str]:
) -> tuple[str | None, str, bool]:
"""Exécute le scraping réel (appelé par scrape_item avec retries)."""
async with async_playwright() as p:
browser = None
context = None
page = None
is_available = True # Par défaut, le produit est disponible
try:
logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}")
@@ -188,9 +190,24 @@ class ScraperService:
page = await context.new_page()
logger.info(f"Navigating to {url} (Timeout: {timeout}ms)")
response = None
try:
# First wait for domcontentloaded - this is the minimum we need
await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
response = await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
# Check HTTP status code
if response:
status = response.status
logger.info(f"HTTP Status: {status}")
# Detect unavailable products by HTTP status
if status in [404, 410, 451, 503]:
logger.warning(f"Product unavailable - HTTP {status}")
is_available = False
elif status >= 400:
logger.warning(f"HTTP error {status}, marking as potentially unavailable")
is_available = False
logger.info(f"Page loaded (domcontentloaded): {url}")
# Then try to wait for networkidle, but don't fail if it times out
@@ -203,6 +220,8 @@ class ScraperService:
except Exception as e:
logger.error(f"Error navigating to {url}: {e}")
# If navigation fails completely, mark as unavailable
is_available = False
# Try to take screenshot anyway if page partially loaded
pass
@@ -347,7 +366,7 @@ class ScraperService:
logger.info(f"Screenshot saved to {filename}")
except Exception as e:
logger.error(f"Screenshot failed: {e}")
return None, ""
return None, "", is_available
# Text Extraction (après le screenshot pour ne pas le perdre)
page_text = ""
@@ -359,15 +378,40 @@ class ScraperService:
# Simple truncation
page_text = raw_text[:text_length]
logger.info(f"Extracted {len(page_text)} characters")
# Check for "product not found" messages in page text
unavailable_patterns = [
"produit introuvable",
"produit indisponible",
"page introuvable",
"404",
"product not found",
"item not found",
"page not found",
"n'existe plus",
"plus disponible",
"no longer available",
"article introuvable",
"cette page n'existe pas",
"this page doesn't exist",
]
page_text_lower = page_text.lower()
for pattern in unavailable_patterns:
if pattern in page_text_lower:
logger.warning(f"Product unavailable - found pattern: '{pattern}'")
is_available = False
break
except Exception as e:
logger.warning(f"Text extraction failed (screenshot saved): {e}")
# On continue car le screenshot a été pris
return filename, page_text
return filename, page_text, is_available
except Exception as e:
logger.error(f"Error scraping {url}: {e}")
return None, ""
return None, "", False # Mark as unavailable on error
finally:
# Fermeture propre des ressources dans l'ordre inverse
+1 -1
View File
@@ -209,7 +209,7 @@ async def _scrape_with_browserless(
"""Scrape une URL avec Browserless + extraction IA"""
try:
# Scraper avec Browserless
screenshot_path, page_text = await ScraperService.scrape_item(
screenshot_path, page_text, is_available = await ScraperService.scrape_item(
url=url,
item_id=None,
smart_scroll=True,
+2
View File
@@ -4,6 +4,7 @@ import { Toaster } from 'sonner'
import Layout from '@/components/layout/Layout'
import Dashboard from '@/pages/Dashboard'
import Search from '@/pages/Search'
import MultiSearch from '@/pages/MultiSearch'
import Login from '@/pages/Login'
import Admin from '@/pages/Admin'
import { useTheme } from '@/hooks/use-theme'
@@ -75,6 +76,7 @@ function AppRoutes() {
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/search" element={<Search />} />
<Route path="/compare" element={<MultiSearch />} />
<Route
path="/admin"
element={
+38 -3
View File
@@ -1,13 +1,15 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock } from 'lucide-react';
import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock, Tag, History } from 'lucide-react';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Marquee } from '@/components/ui/marquee';
import { cn } from '@/lib/utils';
export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom, onCategoryUpdate, categories, onViewHistory }) {
const { t } = useTranslation();
const [showCategorySelect, setShowCategorySelect] = useState(false);
const getStockStatus = (inStock) => {
if (inStock === true) return { label: t('dashboard.inStock'), color: 'bg-green-500/90 text-white border-green-400/50' };
@@ -134,6 +136,34 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
</div>
)}
{/* Category */}
<div className="flex items-center gap-2">
{item.category ? (
<span className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
<Tag className="h-2.5 w-2.5" />
{item.category}
</span>
) : null}
{onCategoryUpdate && (
<Select
value={item.category || ''}
onValueChange={(value) => onCategoryUpdate(item.id, value || null)}
>
<SelectTrigger className="h-6 w-[120px] text-[10px]">
<Tag className="h-2.5 w-2.5 mr-1" />
<SelectValue placeholder="Catégorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Aucune</SelectItem>
{categories && categories.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
<SelectItem value="__new__">+ Nouvelle...</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Description */}
{item.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
@@ -159,6 +189,11 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
</div>
<div className="flex gap-1">
{onViewHistory && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => onViewHistory(item)} title="Historique des prix">
<History className="h-3 w-3" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => onEdit(item)}>
<Edit2 className="h-3 w-3" />
</Button>
@@ -0,0 +1,227 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { X, TrendingDown, TrendingUp, Minus } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
const API_URL = '/api';
export function PriceHistoryModal({ open, onClose, item }) {
const [priceHistory, setPriceHistory] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open && item) {
loadPriceHistory();
}
}, [open, item]);
const loadPriceHistory = async () => {
if (!item) return;
setLoading(true);
try {
const response = await axios.get(`${API_URL}/items/${item.id}/price-history`);
setPriceHistory(response.data);
} catch (error) {
console.error('Error loading price history:', error);
} finally {
setLoading(false);
}
};
if (!item) return null;
// Calculate stats
const prices = priceHistory.map(h => h.price);
const currentPrice = prices[prices.length - 1];
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const avgPrice = prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : 0;
// Calculate price change
const priceChange = prices.length > 1 ? ((currentPrice - prices[0]) / prices[0] * 100) : 0;
// Simple chart rendering
const chartHeight = 200;
const chartWidth = 600;
const padding = 40;
const renderChart = () => {
if (prices.length === 0) return null;
const minY = minPrice * 0.95;
const maxY = maxPrice * 1.05;
const rangeY = maxY - minY;
const points = priceHistory.map((point, index) => {
const x = padding + (index / (priceHistory.length - 1 || 1)) * (chartWidth - 2 * padding);
const y = chartHeight - padding - ((point.price - minY) / rangeY) * (chartHeight - 2 * padding);
return { x, y, price: point.price, date: point.timestamp };
});
const pathData = points.map((point, index) =>
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
).join(' ');
return (
<svg width="100%" height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="mt-4">
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const y = padding + ratio * (chartHeight - 2 * padding);
const price = maxY - ratio * rangeY;
return (
<g key={ratio}>
<line
x1={padding}
y1={y}
x2={chartWidth - padding}
y2={y}
stroke="currentColor"
strokeOpacity="0.1"
/>
<text x={10} y={y + 4} fontSize="10" fill="currentColor" opacity="0.5">
{price.toFixed(2)}€
</text>
</g>
);
})}
{/* Area under curve */}
<path
d={`${pathData} L ${points[points.length - 1].x} ${chartHeight - padding} L ${points[0].x} ${chartHeight - padding} Z`}
fill="currentColor"
fillOpacity="0.1"
/>
{/* Line */}
<path
d={pathData}
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-primary"
/>
{/* Points */}
{points.map((point, index) => (
<g key={index}>
<circle
cx={point.x}
cy={point.y}
r="4"
fill="currentColor"
className="text-primary"
/>
<title>{`${new Date(point.date).toLocaleDateString('fr-FR')}: ${point.price.toFixed(2)}€`}</title>
</g>
))}
</svg>
);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Historique des prix</span>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Product Info */}
<div>
<h3 className="font-semibold line-clamp-2">{item.name}</h3>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-primary"
>
{new URL(item.url).hostname.replace('www.', '')}
</a>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="p-3">
<div className="text-xs text-muted-foreground">Prix actuel</div>
<div className="text-lg font-bold">{currentPrice?.toFixed(2) || '--'}€</div>
</Card>
<Card className="p-3">
<div className="text-xs text-muted-foreground">Prix minimum</div>
<div className="text-lg font-bold text-green-600">{minPrice?.toFixed(2) || '--'}€</div>
</Card>
<Card className="p-3">
<div className="text-xs text-muted-foreground">Prix maximum</div>
<div className="text-lg font-bold text-red-600">{maxPrice?.toFixed(2) || '--'}€</div>
</Card>
<Card className="p-3">
<div className="text-xs text-muted-foreground">Variation</div>
<div className={`text-lg font-bold flex items-center gap-1 ${priceChange > 0 ? 'text-red-600' : priceChange < 0 ? 'text-green-600' : ''}`}>
{priceChange > 0 && <TrendingUp className="h-4 w-4" />}
{priceChange < 0 && <TrendingDown className="h-4 w-4" />}
{priceChange === 0 && <Minus className="h-4 w-4" />}
{Math.abs(priceChange).toFixed(1)}%
</div>
</Card>
</div>
{/* Chart */}
{loading ? (
<div className="flex items-center justify-center h-[200px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : priceHistory.length > 0 ? (
<div>
{renderChart()}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
Aucun historique de prix disponible
</div>
)}
{/* History Table */}
{priceHistory.length > 0 && (
<div className="max-h-[200px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-background border-b">
<tr>
<th className="text-left p-2">Date</th>
<th className="text-right p-2">Prix</th>
<th className="text-right p-2">Variation</th>
</tr>
</thead>
<tbody>
{priceHistory.map((entry, index) => {
const prevPrice = index > 0 ? priceHistory[index - 1].price : entry.price;
const change = ((entry.price - prevPrice) / prevPrice * 100);
return (
<tr key={index} className="border-b">
<td className="p-2">
{new Date(entry.timestamp).toLocaleString('fr-FR')}
</td>
<td className="text-right p-2 font-medium">
{entry.price.toFixed(2)}€
</td>
<td className={`text-right p-2 ${change > 0 ? 'text-red-600' : change < 0 ? 'text-green-600' : ''}`}>
{change !== 0 ? `${change > 0 ? '+' : ''}${change.toFixed(1)}%` : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+2 -1
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Search, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react';
import { LayoutDashboard, Search, TrendingUp, Moon, Sun, Menu, LogOut, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
@@ -22,6 +22,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: TrendingUp, label: 'Comparateur', path: '/compare' },
];
// Add admin link for admin users
+58 -3
View File
@@ -2,12 +2,14 @@ import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Plus, RefreshCw, Search, X } from 'lucide-react';
import { Plus, RefreshCw, Search, X, Filter } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ItemCard } from '@/components/dashboard/ItemCard';
import { ItemModal } from '@/components/dashboard/ItemModal';
import { DeleteConfirmationModal } from '@/components/dashboard/DeleteConfirmationModal';
import { PriceHistoryModal } from '@/components/dashboard/PriceHistoryModal';
const API_URL = '/api';
@@ -15,9 +17,12 @@ export default function Dashboard() {
const { t } = useTranslation();
const [items, setItems] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [categories, setCategories] = useState([]);
const [showAddModal, setShowAddModal] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [itemToDelete, setItemToDelete] = useState(null);
const [historyItem, setHistoryItem] = useState(null);
const [zoomedImage, setZoomedImage] = useState(null);
const refreshItems = async () => {
@@ -31,8 +36,18 @@ export default function Dashboard() {
}
};
const loadCategories = async () => {
try {
const response = await axios.get(`${API_URL}/items/categories/list`);
setCategories(response.data);
} catch (error) {
console.error('Error fetching categories:', error);
}
};
useEffect(() => {
refreshItems();
loadCategories();
const interval = setInterval(refreshItems, 10000);
return () => clearInterval(interval);
}, []);
@@ -75,14 +90,32 @@ export default function Dashboard() {
}
};
const handleCategoryUpdate = async (itemId, category) => {
try {
await axios.patch(`${API_URL}/items/${itemId}/category?category=${encodeURIComponent(category || '')}`);
toast.success('Catégorie mise à jour');
refreshItems();
loadCategories();
} catch (error) {
console.error('Error updating category:', error);
toast.error('Erreur lors de la mise à jour');
}
};
const filteredItems = items.filter(item => {
const term = searchTerm.toLowerCase();
return (
const matchesSearch = (
item.name.toLowerCase().includes(term) ||
item.url.toLowerCase().includes(term) ||
(item.tags && item.tags.toLowerCase().includes(term)) ||
(item.description && item.description.toLowerCase().includes(term))
);
const matchesCategory = selectedCategory === 'all' ||
(selectedCategory === 'uncategorized' && !item.category) ||
item.category === selectedCategory;
return matchesSearch && matchesCategory;
});
return (
@@ -92,7 +125,7 @@ export default function Dashboard() {
<h2 className="text-2xl font-bold tracking-tight">{t('dashboard.title')}</h2>
<p className="text-muted-foreground">{t('dashboard.description')}</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-full md:w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
@@ -102,6 +135,19 @@ export default function Dashboard() {
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-full md:w-[180px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="Catégorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes</SelectItem>
<SelectItem value="uncategorized">Sans catégorie</SelectItem>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={handleRefreshAll}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('dashboard.refreshAll')}
@@ -122,6 +168,9 @@ export default function Dashboard() {
onDelete={setItemToDelete}
onCheck={handleCheck}
onZoom={setZoomedImage}
onCategoryUpdate={handleCategoryUpdate}
onViewHistory={setHistoryItem}
categories={categories}
/>
))}
</div>
@@ -146,6 +195,12 @@ export default function Dashboard() {
onConfirm={handleDelete}
/>
<PriceHistoryModal
open={!!historyItem}
item={historyItem}
onClose={() => setHistoryItem(null)}
/>
{/* Image Zoom Modal */}
{zoomedImage && (
<div
+271
View File
@@ -0,0 +1,271 @@
import React, { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18n';
import { Search as SearchIcon, Loader2, ExternalLink, TrendingUp, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
const API_URL = '/api';
export default function MultiSearch() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [progress, setProgress] = useState(null);
const [results, setResults] = useState([]);
const eventSourceRef = useRef(null);
const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim()) {
toast.error('Veuillez entrer une recherche');
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 - recherche sur TOUS les sites actifs
const params = new URLSearchParams({
q: query.trim(),
max_results: 50, // Plus de résultats pour la comparaison
});
// 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;
// Grouper les résultats par produit similaire (basé sur le titre)
const groupedResults = results.reduce((acc, result) => {
// Simplifier le titre pour le regroupement
const simplifiedTitle = result.title.toLowerCase().substring(0, 50);
if (!acc[simplifiedTitle]) {
acc[simplifiedTitle] = [];
}
acc[simplifiedTitle].push(result);
return acc;
}, {});
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">Comparateur de Prix</h2>
<p className="text-muted-foreground">
Recherchez un produit sur tous les sites et comparez les prix
</p>
</div>
</div>
{/* Search Form */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="space-y-4">
<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 sur tous les sites..."
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" />
Comparer
</>
)}
</Button>
</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>{progressPercent}%</span>
</div>
<Progress value={progressPercent} />
</div>
</CardContent>
</Card>
)}
{/* Results Table */}
{results.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Résultats de comparaison
</CardTitle>
<CardDescription>
{results.length} produit{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''} avec prix
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-3 font-medium">Site</th>
<th className="text-left p-3 font-medium">Produit</th>
<th className="text-right p-3 font-medium">Prix</th>
<th className="text-center p-3 font-medium">Stock</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{results.map((result, index) => (
<tr key={index} className="border-b hover:bg-muted/50 transition-colors">
<td className="p-3">
<div className="flex flex-col">
<span className="font-medium">{result.site_name}</span>
<span className="text-xs text-muted-foreground">{result.site_domain}</span>
</div>
</td>
<td className="p-3">
<div className="max-w-md">
<p className="font-medium line-clamp-2">{result.title}</p>
</div>
</td>
<td className="p-3 text-right">
{result.price !== null ? (
<span className="text-lg font-bold text-primary">
{result.price.toFixed(2)} {result.currency || 'EUR'}
</span>
) : (
<span className="text-sm text-muted-foreground">Prix non disponible</span>
)}
</td>
<td className="p-3 text-center">
{result.in_stock === true && (
<Badge variant="success" className="bg-green-500/10 text-green-700 dark:text-green-400">
<Package className="h-3 w-3 mr-1" />
En stock
</Badge>
)}
{result.in_stock === false && (
<Badge variant="destructive">Rupture</Badge>
)}
{result.in_stock === null && (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
<td className="p-3">
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.open(result.url, '_blank')}
>
<ExternalLink className="h-3 w-3 mr-1" />
Voir
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleAddToMonitoring(result)}
>
Suivre
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* 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
</p>
</CardContent>
</Card>
)}
</div>
);
}
+31 -1
View File
@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { SearchResultCard } from '@/components/search/SearchResultCard';
const API_URL = '/api';
const STORAGE_KEY = 'priceflow_search_state';
export default function Search() {
const { t } = useTranslation();
@@ -22,6 +23,32 @@ export default function Search() {
const [results, setResults] = useState([]);
const eventSourceRef = useRef(null);
// Restore search state from sessionStorage on mount
useEffect(() => {
const savedState = sessionStorage.getItem(STORAGE_KEY);
if (savedState) {
try {
const { query: savedQuery, selectedSiteId: savedSiteId, results: savedResults } = JSON.parse(savedState);
if (savedQuery) setQuery(savedQuery);
if (savedSiteId) setSelectedSiteId(savedSiteId);
if (savedResults) setResults(savedResults);
} catch (error) {
console.error('Error restoring search state:', error);
}
}
}, []);
// Save search state to sessionStorage whenever it changes
useEffect(() => {
if (query || results.length > 0) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
query,
selectedSiteId,
results
}));
}
}, [query, selectedSiteId, results]);
// Charger les sites au démarrage
useEffect(() => {
loadSites();
@@ -32,7 +59,7 @@ export default function Search() {
const response = await axios.get(`${API_URL}/search-sites`);
const activeSites = response.data.filter(site => site.is_active);
setSites(activeSites);
// Sélectionner le premier site actif par défaut
// Sélectionner le premier site actif par défaut si aucun site n'est sélectionné
if (activeSites.length > 0 && !selectedSiteId) {
setSelectedSiteId(activeSites[0].id.toString());
}
@@ -49,6 +76,9 @@ export default function Search() {
return;
}
// Clear previous storage when starting new search
sessionStorage.removeItem(STORAGE_KEY);
// Fermer l'EventSource précédent si existant
if (eventSourceRef.current) {
eventSourceRef.current.close();
+29 -2
View File
@@ -639,9 +639,36 @@ export default function Settings() {
<Label>Name</Label>
<Input required value={newProfile.name} onChange={(e) => setNewProfile({ ...newProfile, name: e.target.value })} placeholder="e.g., Email Alerts" />
</div>
<div className="space-y-2">
<div className="space-y-2 md:col-span-2">
<Label>Apprise URL</Label>
<Input required value={newProfile.apprise_url} onChange={(e) => setNewProfile({ ...newProfile, apprise_url: e.target.value })} placeholder="mailto://user:pass@gmail.com" />
<Input
required
value={newProfile.apprise_url}
onChange={(e) => setNewProfile({ ...newProfile, apprise_url: e.target.value })}
placeholder="mailto://user:pass@gmail.com"
className="font-mono text-sm"
/>
<div className="space-y-1 text-xs text-muted-foreground">
<p className="font-medium">Exemples d'URLs Apprise :</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-3">
<p>• <span className="font-mono">mailto://user:pass@gmail.com</span> - Email</p>
<p>• <span className="font-mono">tgram://bottoken/ChatID</span> - Telegram</p>
<p>• <span className="font-mono">discord://webhook_id/webhook_token</span> - Discord</p>
<p>• <span className="font-mono">slack://tokenA/tokenB/tokenC</span> - Slack</p>
<p>• <span className="font-mono">ntfy://topic</span> - Ntfy</p>
<p>• <span className="font-mono">gotify://hostname/token</span> - Gotify</p>
</div>
<p className="pt-1">
<a
href="https://github.com/caronc/apprise/wiki"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
📚 Documentation complète Apprise
</a>
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+2
View File
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS items (
check_interval_minutes INTEGER DEFAULT 60,
current_price FLOAT,
in_stock BOOLEAN,
is_available BOOLEAN DEFAULT TRUE,
category VARCHAR(255),
tags VARCHAR(512),
description TEXT,
current_price_confidence FLOAT,