feat: amélioration UI - popup logs, ratio image, lien Amazon header
- Ajout popup de logs en bas à droite pour les commandes backend - Ajout slider ratio image/infos dans Settings (mise à jour temps réel) - Déplacement du lien "Voir sur Amazon" dans le header de la carte - Amélioration du formatage des dates dans le graphique (adaptatif selon span) - Ajout lignes de référence prix conseillé/min 30j dans PriceChart - Ajout sélecteur de période (7j/30j/90j/Tout) dans le graphique Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
"ui": {
|
||||
"theme": "gruvbox_vintage_dark",
|
||||
"button_mode": "text/icon",
|
||||
"columns_desktop": 3,
|
||||
"columns_desktop": 4,
|
||||
"card_density": "comfortable",
|
||||
"image_ratio": 43,
|
||||
"show_fields": {
|
||||
"price": true,
|
||||
"stock": true,
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -1586,6 +1587,15 @@
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-annotation": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import DebugPage from "./pages/DebugPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import useProductStore from "./stores/useProductStore";
|
||||
import AddProductModal from "./components/products/AddProductModal";
|
||||
import CommandLogPopup from "./components/common/CommandLogPopup";
|
||||
import * as api from "./api/client";
|
||||
|
||||
const FRONTEND_VERSION = "0.1.0";
|
||||
@@ -83,6 +84,7 @@ const App = () => (
|
||||
<Route path="/debug" element={<DebugPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
<CommandLogPopup />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Tooltip,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
import annotationPlugin from "chartjs-plugin-annotation";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import * as api from "../../api/client";
|
||||
|
||||
@@ -20,7 +21,8 @@ ChartJS.register(
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Filler
|
||||
Filler,
|
||||
annotationPlugin
|
||||
);
|
||||
|
||||
// Couleurs Gruvbox
|
||||
@@ -32,8 +34,17 @@ const COLORS = {
|
||||
text: "#ebdbb2",
|
||||
muted: "#928374",
|
||||
bg: "#282828",
|
||||
purple: "#d3869b",
|
||||
};
|
||||
|
||||
// Périodes disponibles
|
||||
const PERIODS = [
|
||||
{ label: "7j", days: 7 },
|
||||
{ label: "30j", days: 30 },
|
||||
{ label: "90j", days: 90 },
|
||||
{ label: "Tout", days: 365 },
|
||||
];
|
||||
|
||||
// Convertit une date UTC (sans timezone) en objet Date local
|
||||
const parseUTCDate = (dateStr) => {
|
||||
if (!dateStr) return null;
|
||||
@@ -42,10 +53,26 @@ const parseUTCDate = (dateStr) => {
|
||||
return new Date(normalized);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const formatDateLabel = (dateStr, spanDays) => {
|
||||
const date = parseUTCDate(dateStr);
|
||||
if (!date) return "-";
|
||||
|
||||
// Adapter le format selon l'écart réel entre les dates
|
||||
if (spanDays < 1) {
|
||||
// Moins d'un jour : afficher uniquement l'heure
|
||||
return date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||
} else if (spanDays <= 7) {
|
||||
// 1 à 7 jours : afficher date + heure
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} else {
|
||||
// Plus de 7 jours : afficher uniquement la date
|
||||
return date.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price) => {
|
||||
@@ -56,6 +83,15 @@ const formatPrice = (price) => {
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const formatPriceShort = (price) => {
|
||||
if (price == null) return "-";
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const calculateTrend = (snapshots) => {
|
||||
if (!snapshots || snapshots.length < 2) {
|
||||
return { direction: "stable", percent: 0, color: COLORS.yellow, arrow: "→" };
|
||||
@@ -81,16 +117,17 @@ const calculateTrend = (snapshots) => {
|
||||
return { direction: "stable", percent: percentChange, color: COLORS.yellow, arrow: "→" };
|
||||
};
|
||||
|
||||
const PriceChart = ({ productId }) => {
|
||||
const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSnapshots = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.fetchSnapshots(productId, 30);
|
||||
const data = await api.fetchSnapshots(productId, selectedPeriod);
|
||||
setSnapshots(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -101,7 +138,7 @@ const PriceChart = ({ productId }) => {
|
||||
};
|
||||
|
||||
fetchSnapshots();
|
||||
}, [productId]);
|
||||
}, [productId, selectedPeriod]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -126,7 +163,18 @@ const PriceChart = ({ productId }) => {
|
||||
|
||||
// Préparer les données (inverser pour ordre chronologique)
|
||||
const sortedSnapshots = [...snapshots].reverse();
|
||||
const labels = sortedSnapshots.map((s) => formatDate(s.scrape_le));
|
||||
|
||||
// Calculer le span réel entre la première et dernière date
|
||||
let spanDays = 0;
|
||||
if (sortedSnapshots.length >= 2) {
|
||||
const firstDate = parseUTCDate(sortedSnapshots[0].scrape_le);
|
||||
const lastDate = parseUTCDate(sortedSnapshots[sortedSnapshots.length - 1].scrape_le);
|
||||
if (firstDate && lastDate) {
|
||||
spanDays = (lastDate - firstDate) / (1000 * 60 * 60 * 24);
|
||||
}
|
||||
}
|
||||
|
||||
const labels = sortedSnapshots.map((s) => formatDateLabel(s.scrape_le, spanDays));
|
||||
const prices = sortedSnapshots.map((s) => s.prix_actuel);
|
||||
|
||||
// Calculer min/max
|
||||
@@ -137,6 +185,49 @@ const PriceChart = ({ productId }) => {
|
||||
// Calculer la tendance
|
||||
const trend = calculateTrend(snapshots);
|
||||
|
||||
// Préparer les annotations (lignes de référence)
|
||||
const annotations = {};
|
||||
|
||||
// Ligne prix conseillé (si disponible et dans une plage raisonnable)
|
||||
if (prixConseille && prixConseille > maxPrice * 0.8) {
|
||||
annotations.prixConseille = {
|
||||
type: "line",
|
||||
yMin: prixConseille,
|
||||
yMax: prixConseille,
|
||||
borderColor: COLORS.muted,
|
||||
borderWidth: 1,
|
||||
borderDash: [5, 5],
|
||||
label: {
|
||||
display: true,
|
||||
content: `Conseillé ${formatPriceShort(prixConseille)}`,
|
||||
position: "end",
|
||||
backgroundColor: "transparent",
|
||||
color: COLORS.muted,
|
||||
font: { size: 9 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Ligne prix min 30j (si disponible)
|
||||
if (prixMin30j && prixMin30j <= minPrice * 1.1) {
|
||||
annotations.prixMin30j = {
|
||||
type: "line",
|
||||
yMin: prixMin30j,
|
||||
yMax: prixMin30j,
|
||||
borderColor: COLORS.green,
|
||||
borderWidth: 1,
|
||||
borderDash: [3, 3],
|
||||
label: {
|
||||
display: true,
|
||||
content: `Min 30j ${formatPriceShort(prixMin30j)}`,
|
||||
position: "start",
|
||||
backgroundColor: "transparent",
|
||||
color: COLORS.green,
|
||||
font: { size: 9 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: [
|
||||
@@ -172,6 +263,9 @@ const PriceChart = ({ productId }) => {
|
||||
label: (context) => formatPrice(context.raw),
|
||||
},
|
||||
},
|
||||
annotation: {
|
||||
annotations,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
@@ -182,7 +276,7 @@ const PriceChart = ({ productId }) => {
|
||||
color: COLORS.muted,
|
||||
font: { size: 10 },
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 5,
|
||||
maxTicksLimit: selectedPeriod <= 7 ? 7 : 5,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
@@ -192,8 +286,10 @@ const PriceChart = ({ productId }) => {
|
||||
ticks: {
|
||||
color: COLORS.muted,
|
||||
font: { size: 10 },
|
||||
callback: (value) => formatPrice(value),
|
||||
callback: (value) => formatPriceShort(value),
|
||||
},
|
||||
// Étendre l'échelle Y pour inclure le prix conseillé si présent
|
||||
suggestedMax: prixConseille ? Math.max(maxPrice, prixConseille) * 1.02 : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -210,6 +306,19 @@ const PriceChart = ({ productId }) => {
|
||||
|
||||
return (
|
||||
<div className="price-chart-container">
|
||||
{/* Sélecteur de période */}
|
||||
<div className="price-chart-period-selector">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.days}
|
||||
className={`period-btn ${selectedPeriod === p.days ? "active" : ""}`}
|
||||
onClick={() => setSelectedPeriod(p.days)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="price-chart">
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
|
||||
49
frontend/src/components/common/CommandLogPopup.jsx
Normal file
49
frontend/src/components/common/CommandLogPopup.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import useCommandLogStore from "../../stores/useCommandLogStore";
|
||||
|
||||
const CommandLogPopup = () => {
|
||||
const { logs, visible, hide } = useCommandLogStore();
|
||||
|
||||
if (!visible || logs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Afficher les 5 derniers logs (les plus récents en premier)
|
||||
const displayLogs = logs.slice(0, 5);
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "fa-check-circle";
|
||||
case "error":
|
||||
return "fa-times-circle";
|
||||
case "warning":
|
||||
return "fa-exclamation-triangle";
|
||||
default:
|
||||
return "fa-info-circle";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="command-log-popup">
|
||||
<div className="command-log-header">
|
||||
<span className="command-log-title">
|
||||
<i className="fa-solid fa-terminal"></i> Logs
|
||||
</span>
|
||||
<button className="command-log-close" onClick={hide} title="Fermer">
|
||||
<i className="fa-solid fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="command-log-content">
|
||||
{displayLogs.map((log) => (
|
||||
<div key={log.id} className={`command-log-line ${log.type}`}>
|
||||
<span className="log-timestamp">{log.timestamp}</span>
|
||||
<i className={`fa-solid ${getTypeIcon(log.type)} log-icon`}></i>
|
||||
<span className="log-message">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandLogPopup;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import useProductStore from "../../stores/useProductStore";
|
||||
import useConfigStore from "../../stores/useConfigStore";
|
||||
import PriceChart from "../charts/PriceChart";
|
||||
import Lightbox from "../common/Lightbox";
|
||||
import ProductDetailModal from "./ProductDetailModal";
|
||||
@@ -19,6 +20,7 @@ const formatNumber = (num) => {
|
||||
|
||||
const ProductCard = ({ product }) => {
|
||||
const { scrapeProduct, deleteProduct, scraping } = useProductStore();
|
||||
const imageRatio = useConfigStore((state) => state.config.ui?.image_ratio || 40);
|
||||
const isScraping = scraping[product.id];
|
||||
const [showLightbox, setShowLightbox] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
@@ -52,12 +54,23 @@ const ProductCard = ({ product }) => {
|
||||
<span className="boutique">
|
||||
<i className="fa-brands fa-amazon"></i> {product.boutique}
|
||||
</span>
|
||||
<div className="header-right">
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="header-link"
|
||||
title="Voir sur Amazon"
|
||||
>
|
||||
<i className="fa-solid fa-external-link"></i>
|
||||
</a>
|
||||
{product.actif ? (
|
||||
<span className="status active">Actif</span>
|
||||
) : (
|
||||
<span className="status inactive">Inactif</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="product-title clickable"
|
||||
@@ -67,7 +80,7 @@ const ProductCard = ({ product }) => {
|
||||
{product.titre || "Titre non disponible"}
|
||||
</h3>
|
||||
|
||||
<div className="product-body">
|
||||
<div className="product-body" style={{ "--image-ratio": imageRatio }}>
|
||||
<div
|
||||
className={`product-image ${product.url_image ? "clickable" : ""}`}
|
||||
onClick={() => product.url_image && setShowLightbox(true)}
|
||||
@@ -169,22 +182,21 @@ const ProductCard = ({ product }) => {
|
||||
{/* Méta */}
|
||||
<div className="product-meta">
|
||||
<span className="asin">Ref: {product.asin}</span>
|
||||
{product.categorie && <span className="category">{product.categorie}</span>}
|
||||
{product.categorie_amazon && (
|
||||
<span className="category" title={product.categorie_amazon}>
|
||||
<i className="fa-solid fa-folder-tree"></i> {product.categorie_amazon.split(" > ").slice(-1)[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="product-link"
|
||||
>
|
||||
<i className="fa-solid fa-external-link"></i> Voir sur Amazon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graphique historique 30j */}
|
||||
<PriceChart productId={product.id} />
|
||||
{/* Graphique historique */}
|
||||
<PriceChart
|
||||
productId={product.id}
|
||||
prixConseille={product.prix_conseille}
|
||||
prixMin30j={product.prix_min_30j}
|
||||
/>
|
||||
|
||||
<div className="product-actions">
|
||||
<button
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import * as api from "../api/client";
|
||||
import useConfigStore from "../stores/useConfigStore";
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "-";
|
||||
return new Date(dateStr).toLocaleString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const SettingsPage = () => {
|
||||
const [frontendConfig, setFrontendConfig] = useState(null);
|
||||
const [backendConfig, setBackendConfig] = useState(null);
|
||||
const [dbInfo, setDbInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
@@ -17,12 +38,14 @@ const SettingsPage = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [fe, be] = await Promise.all([
|
||||
const [fe, be, db] = await Promise.all([
|
||||
api.fetchFrontendConfig(),
|
||||
api.fetchBackendConfig(),
|
||||
api.fetchDatabaseInfo().catch(() => null),
|
||||
]);
|
||||
setFrontendConfig(fe);
|
||||
setBackendConfig(be);
|
||||
setDbInfo(db);
|
||||
} catch (err) {
|
||||
setError("Erreur lors du chargement des configurations: " + err.message);
|
||||
} finally {
|
||||
@@ -62,6 +85,47 @@ const SettingsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackup = () => {
|
||||
api.downloadDatabaseBackup();
|
||||
};
|
||||
|
||||
const handleRestoreClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith(".db")) {
|
||||
setError("Le fichier doit être un fichier .db");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("Êtes-vous sûr de vouloir restaurer cette base de données ? Cette action remplacera toutes les données actuelles.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoring(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const result = await api.restoreDatabase(file);
|
||||
setSuccess(`Base de données restaurée (${formatSize(result.size_bytes)})`);
|
||||
// Recharger les infos
|
||||
const db = await api.fetchDatabaseInfo();
|
||||
setDbInfo(db);
|
||||
setTimeout(() => setSuccess(null), 5000);
|
||||
} catch (err) {
|
||||
setError("Erreur restauration: " + err.message);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
// Reset l'input file
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="settings-page">
|
||||
@@ -156,6 +220,31 @@ const SettingsPage = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Ratio image / infos</label>
|
||||
<div className="slider-group">
|
||||
<span className="slider-label-side">Info</span>
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
max="60"
|
||||
value={frontendConfig.ui?.image_ratio || 40}
|
||||
onChange={(e) => {
|
||||
const ratio = parseInt(e.target.value);
|
||||
setFrontendConfig({
|
||||
...frontendConfig,
|
||||
ui: { ...frontendConfig.ui, image_ratio: ratio },
|
||||
});
|
||||
// Mise à jour temps réel sans sauvegarde
|
||||
useConfigStore.getState().setImageRatioLocal(ratio);
|
||||
}}
|
||||
/>
|
||||
<span className="slider-label-side">Image</span>
|
||||
<span className="slider-value">{frontendConfig.ui?.image_ratio || 40}%</span>
|
||||
</div>
|
||||
<span className="form-hint">Proportion de l'image dans la vignette produit</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Rafraîchissement auto (secondes)</label>
|
||||
<input
|
||||
@@ -401,6 +490,67 @@ const SettingsPage = () => {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section Database Backup */}
|
||||
<section className="settings-section">
|
||||
<div className="section-header">
|
||||
<h3>
|
||||
<i className="fa-solid fa-database"></i> Base de données
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="settings-form">
|
||||
{dbInfo && (
|
||||
<div className="db-info">
|
||||
<div className="db-info-row">
|
||||
<span className="label">Fichier</span>
|
||||
<span className="value mono">{dbInfo.filename}</span>
|
||||
</div>
|
||||
<div className="db-info-row">
|
||||
<span className="label">Taille</span>
|
||||
<span className="value">{formatSize(dbInfo.size_bytes)}</span>
|
||||
</div>
|
||||
<div className="db-info-row">
|
||||
<span className="label">Dernière modification</span>
|
||||
<span className="value">{formatDate(dbInfo.modified_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="db-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleDownloadBackup}
|
||||
>
|
||||
<i className="fa-solid fa-download"></i> Télécharger backup
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleRestoreClick}
|
||||
disabled={restoring}
|
||||
>
|
||||
{restoring ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin"></i> Restauration...</>
|
||||
) : (
|
||||
<><i className="fa-solid fa-upload"></i> Restaurer</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".db"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="form-hint">
|
||||
La restauration créera automatiquement une sauvegarde de la base actuelle avant de la remplacer.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
65
frontend/src/stores/useCommandLogStore.js
Normal file
65
frontend/src/stores/useCommandLogStore.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
const MAX_LOGS = 50;
|
||||
const AUTO_HIDE_DELAY = 5000; // 5 secondes après la dernière activité
|
||||
|
||||
const useCommandLogStore = create((set, get) => ({
|
||||
logs: [],
|
||||
visible: false,
|
||||
autoHideTimer: null,
|
||||
|
||||
// Ajouter un log
|
||||
addLog: (message, type = "info") => {
|
||||
const timestamp = new Date().toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
// Annuler le timer existant
|
||||
if (state.autoHideTimer) {
|
||||
clearTimeout(state.autoHideTimer);
|
||||
}
|
||||
|
||||
// Nouveau timer pour auto-hide
|
||||
const newTimer = setTimeout(() => {
|
||||
set({ visible: false });
|
||||
}, AUTO_HIDE_DELAY);
|
||||
|
||||
const newLogs = [
|
||||
{ id: Date.now(), timestamp, message, type },
|
||||
...state.logs,
|
||||
].slice(0, MAX_LOGS);
|
||||
|
||||
return {
|
||||
logs: newLogs,
|
||||
visible: true,
|
||||
autoHideTimer: newTimer,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Types de logs spécifiques
|
||||
logInfo: (message) => get().addLog(message, "info"),
|
||||
logSuccess: (message) => get().addLog(message, "success"),
|
||||
logError: (message) => get().addLog(message, "error"),
|
||||
logWarning: (message) => get().addLog(message, "warning"),
|
||||
|
||||
// Masquer le popup
|
||||
hide: () => {
|
||||
const state = get();
|
||||
if (state.autoHideTimer) {
|
||||
clearTimeout(state.autoHideTimer);
|
||||
}
|
||||
set({ visible: false, autoHideTimer: null });
|
||||
},
|
||||
|
||||
// Afficher le popup
|
||||
show: () => set({ visible: true }),
|
||||
|
||||
// Vider les logs
|
||||
clear: () => set({ logs: [], visible: false }),
|
||||
}));
|
||||
|
||||
export default useCommandLogStore;
|
||||
@@ -8,6 +8,7 @@ const DEFAULT_CONFIG = {
|
||||
button_mode: "text/icon",
|
||||
columns_desktop: 3,
|
||||
card_density: "comfortable",
|
||||
image_ratio: 40, // % de la hauteur du body pour l'image (20-60)
|
||||
show_fields: {
|
||||
price: true,
|
||||
stock: true,
|
||||
@@ -49,6 +50,24 @@ const useConfigStore = create((set) => ({
|
||||
const state = useConfigStore.getState();
|
||||
return state.config.ui?.show_fields || DEFAULT_CONFIG.ui.show_fields;
|
||||
},
|
||||
|
||||
getImageRatio: () => {
|
||||
const state = useConfigStore.getState();
|
||||
return state.config.ui?.image_ratio || 40;
|
||||
},
|
||||
|
||||
// Setter local pour mise à jour en temps réel (sans sauvegarder)
|
||||
setImageRatioLocal: (ratio) => {
|
||||
set((state) => ({
|
||||
config: {
|
||||
...state.config,
|
||||
ui: {
|
||||
...state.config.ui,
|
||||
image_ratio: ratio,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
export default useConfigStore;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import * as api from "../api/client";
|
||||
import useCommandLogStore from "./useCommandLogStore";
|
||||
|
||||
const useProductStore = create((set, get) => ({
|
||||
// State
|
||||
@@ -11,46 +12,58 @@ const useProductStore = create((set, get) => ({
|
||||
// Actions
|
||||
fetchProducts: async () => {
|
||||
set({ loading: true, error: null });
|
||||
useCommandLogStore.getState().logInfo("Chargement des produits...");
|
||||
try {
|
||||
const products = await api.fetchProducts();
|
||||
set({ products, loading: false });
|
||||
useCommandLogStore.getState().logSuccess(`${products.length} produits chargés`);
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false });
|
||||
useCommandLogStore.getState().logError(`Erreur: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
addProduct: async (data) => {
|
||||
set({ loading: true, error: null });
|
||||
useCommandLogStore.getState().logInfo("Ajout du produit...");
|
||||
try {
|
||||
const newProduct = await api.createProduct(data);
|
||||
// Rafraîchir la liste complète pour avoir les données enrichies (ProductWithSnapshot)
|
||||
await get().fetchProducts();
|
||||
set({ loading: false });
|
||||
useCommandLogStore.getState().logSuccess(`Produit ajouté: ${newProduct.titre || newProduct.asin}`);
|
||||
return newProduct;
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false });
|
||||
useCommandLogStore.getState().logError(`Erreur ajout: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
deleteProduct: async (id) => {
|
||||
set({ error: null });
|
||||
useCommandLogStore.getState().logInfo(`Suppression du produit #${id}...`);
|
||||
try {
|
||||
await api.deleteProduct(id);
|
||||
set((state) => ({
|
||||
products: state.products.filter((p) => p.id !== id),
|
||||
}));
|
||||
useCommandLogStore.getState().logSuccess(`Produit #${id} supprimé`);
|
||||
} catch (err) {
|
||||
set({ error: err.message });
|
||||
useCommandLogStore.getState().logError(`Erreur suppression: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
scrapeProduct: async (id) => {
|
||||
const product = get().products.find((p) => p.id === id);
|
||||
const productName = product?.titre?.substring(0, 30) || `#${id}`;
|
||||
set((state) => ({
|
||||
scraping: { ...state.scraping, [id]: true },
|
||||
error: null,
|
||||
}));
|
||||
useCommandLogStore.getState().logInfo(`Scraping: ${productName}...`);
|
||||
try {
|
||||
const result = await api.scrapeProduct(id);
|
||||
// Refresh la liste pour avoir les nouvelles données
|
||||
@@ -59,25 +72,48 @@ const useProductStore = create((set, get) => ({
|
||||
const { [id]: _, ...rest } = state.scraping;
|
||||
return { scraping: rest };
|
||||
});
|
||||
const status = result.status === "success" ? "logSuccess" : "logWarning";
|
||||
useCommandLogStore.getState()[status](`Scrape ${productName}: ${result.status}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
set((state) => {
|
||||
const { [id]: _, ...rest } = state.scraping;
|
||||
return { scraping: rest, error: err.message };
|
||||
});
|
||||
useCommandLogStore.getState().logError(`Erreur scrape: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
scrapeAll: async () => {
|
||||
set({ loading: true, error: null });
|
||||
useCommandLogStore.getState().logInfo("Scraping de tous les produits...");
|
||||
try {
|
||||
const result = await api.scrapeAll();
|
||||
await get().fetchProducts();
|
||||
set({ loading: false });
|
||||
useCommandLogStore.getState().logSuccess(
|
||||
`Scrape terminé: ${result.success}/${result.total} réussis`
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false });
|
||||
useCommandLogStore.getState().logError(`Erreur scrape all: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
updateProduct: async (id, data) => {
|
||||
set({ error: null });
|
||||
useCommandLogStore.getState().logInfo(`Mise à jour produit #${id}...`);
|
||||
try {
|
||||
await api.updateProduct(id, data);
|
||||
// Rafraîchir la liste pour avoir les données mises à jour
|
||||
await get().fetchProducts();
|
||||
useCommandLogStore.getState().logSuccess(`Produit #${id} mis à jour`);
|
||||
} catch (err) {
|
||||
set({ error: err.message });
|
||||
useCommandLogStore.getState().logError(`Erreur mise à jour: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -248,6 +248,33 @@ a {
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
color: $text-muted;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: $card;
|
||||
color: $accent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
@@ -287,6 +314,7 @@ a {
|
||||
}
|
||||
|
||||
.product-body {
|
||||
--image-ratio: 40; // Valeur par défaut, override par CSS variable
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
@@ -294,9 +322,12 @@ a {
|
||||
}
|
||||
|
||||
.product-image {
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
// Taille basée sur le ratio (variable CSS --image-ratio en %)
|
||||
// flex-basis utilise le ratio pour le partage horizontal
|
||||
flex: 0 0 calc(var(--image-ratio) * 1%);
|
||||
max-width: calc(var(--image-ratio) * 1%);
|
||||
min-width: 60px;
|
||||
aspect-ratio: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
@@ -305,6 +336,7 @@ a {
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
transition: flex-basis 0.2s ease, max-width 0.2s ease;
|
||||
|
||||
&.clickable {
|
||||
cursor: zoom-in;
|
||||
@@ -340,11 +372,14 @@ a {
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
// Prend le reste de l'espace (100% - image ratio)
|
||||
flex: 1 1 calc((100 - var(--image-ratio)) * 1%);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
max-height: 200px; // Limite pour éviter débordement
|
||||
}
|
||||
|
||||
.product-title {
|
||||
@@ -543,6 +578,34 @@ a {
|
||||
color: $accent-red;
|
||||
}
|
||||
|
||||
.price-chart-period-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.period-btn {
|
||||
background: transparent;
|
||||
border: 1px solid $text-muted;
|
||||
color: $text-muted;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: $text;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $accent;
|
||||
border-color: $accent;
|
||||
color: $bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price-chart-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1614,12 +1677,20 @@ a {
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 30px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
.slider-label-side {
|
||||
font-size: 0.75rem;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
min-width: 35px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
@@ -1687,3 +1758,293 @@ a {
|
||||
background: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// Mode édition dans ProductDetailModal
|
||||
.modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: $card;
|
||||
color: $accent;
|
||||
border: 1px solid $accent;
|
||||
|
||||
&:hover {
|
||||
background: $accent;
|
||||
color: $bg;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: $accent-green;
|
||||
color: $bg;
|
||||
|
||||
&:hover {
|
||||
background: darken($accent-green, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: $card;
|
||||
color: $text-muted;
|
||||
|
||||
&:hover {
|
||||
background: $card-hover;
|
||||
color: $text;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
background: $bg;
|
||||
border: 1px solid $card-hover;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: $text;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $accent;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
background: $card-hover;
|
||||
border-radius: 11px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: $text;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background: $accent-green;
|
||||
|
||||
&::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-data-row .edit-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Database backup section
|
||||
.db-info {
|
||||
background: $bg;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.db-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $card-hover;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $text-muted;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: $text;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&.mono {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.db-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.btn-secondary {
|
||||
background: $card;
|
||||
color: $text;
|
||||
border: 1px solid $text-muted;
|
||||
|
||||
&:hover {
|
||||
background: $card-hover;
|
||||
border-color: $text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Command Log Popup
|
||||
.command-log-popup {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 40px);
|
||||
background: $bg;
|
||||
border: 1px solid $card-hover;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba($accent, 0.1);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.command-log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: $bg-soft;
|
||||
border-bottom: 1px solid $card-hover;
|
||||
}
|
||||
|
||||
.command-log-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $accent;
|
||||
|
||||
i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-log-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: $card;
|
||||
color: $text;
|
||||
}
|
||||
}
|
||||
|
||||
.command-log-content {
|
||||
padding: 8px 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.command-log-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba($card, 0.5);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: $text;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// Types
|
||||
&.info {
|
||||
.log-icon { color: $accent-aqua; }
|
||||
}
|
||||
|
||||
&.success {
|
||||
.log-icon { color: $accent-green; }
|
||||
.log-message { color: $accent-green; }
|
||||
}
|
||||
|
||||
&.error {
|
||||
.log-icon { color: $accent-red; }
|
||||
.log-message { color: $accent-red; }
|
||||
}
|
||||
|
||||
&.warning {
|
||||
.log-icon { color: $accent-yellow; }
|
||||
.log-message { color: $accent-yellow; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user