feat: Add initial database migration and UI label component, alongside frontend dependency installation.

This commit is contained in:
Michael committed 2025-11-23 14:37:35 +01:00
1 parent 5884aa2378
commit 8d124bf0f8
17 files changed
+859 -40

No files matched your search

+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">