mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-21 14:18:11 +02:00
feat: Add initial database migration and UI label component, alongside frontend dependency installation.
This commit is contained in:
1 parent
5884aa2378
commit
8d124bf0f8
17 files changed
+859
-40
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user