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": {
|
"ui": {
|
||||||
"theme": "gruvbox_vintage_dark",
|
"theme": "gruvbox_vintage_dark",
|
||||||
"button_mode": "text/icon",
|
"button_mode": "text/icon",
|
||||||
"columns_desktop": 3,
|
"columns_desktop": 4,
|
||||||
"card_density": "comfortable",
|
"card_density": "comfortable",
|
||||||
|
"image_ratio": 43,
|
||||||
"show_fields": {
|
"show_fields": {
|
||||||
"price": true,
|
"price": true,
|
||||||
"stock": true,
|
"stock": true,
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.5.0",
|
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -1586,6 +1587,15 @@
|
|||||||
"pnpm": ">=8"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.5.0",
|
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import DebugPage from "./pages/DebugPage";
|
|||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import useProductStore from "./stores/useProductStore";
|
import useProductStore from "./stores/useProductStore";
|
||||||
import AddProductModal from "./components/products/AddProductModal";
|
import AddProductModal from "./components/products/AddProductModal";
|
||||||
|
import CommandLogPopup from "./components/common/CommandLogPopup";
|
||||||
import * as api from "./api/client";
|
import * as api from "./api/client";
|
||||||
|
|
||||||
const FRONTEND_VERSION = "0.1.0";
|
const FRONTEND_VERSION = "0.1.0";
|
||||||
@@ -83,6 +84,7 @@ const App = () => (
|
|||||||
<Route path="/debug" element={<DebugPage />} />
|
<Route path="/debug" element={<DebugPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<CommandLogPopup />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Filler,
|
Filler,
|
||||||
} from "chart.js";
|
} from "chart.js";
|
||||||
|
import annotationPlugin from "chartjs-plugin-annotation";
|
||||||
import { Line } from "react-chartjs-2";
|
import { Line } from "react-chartjs-2";
|
||||||
import * as api from "../../api/client";
|
import * as api from "../../api/client";
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ ChartJS.register(
|
|||||||
LineElement,
|
LineElement,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Filler
|
Filler,
|
||||||
|
annotationPlugin
|
||||||
);
|
);
|
||||||
|
|
||||||
// Couleurs Gruvbox
|
// Couleurs Gruvbox
|
||||||
@@ -32,8 +34,17 @@ const COLORS = {
|
|||||||
text: "#ebdbb2",
|
text: "#ebdbb2",
|
||||||
muted: "#928374",
|
muted: "#928374",
|
||||||
bg: "#282828",
|
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
|
// Convertit une date UTC (sans timezone) en objet Date local
|
||||||
const parseUTCDate = (dateStr) => {
|
const parseUTCDate = (dateStr) => {
|
||||||
if (!dateStr) return null;
|
if (!dateStr) return null;
|
||||||
@@ -42,10 +53,26 @@ const parseUTCDate = (dateStr) => {
|
|||||||
return new Date(normalized);
|
return new Date(normalized);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDateLabel = (dateStr, spanDays) => {
|
||||||
const date = parseUTCDate(dateStr);
|
const date = parseUTCDate(dateStr);
|
||||||
if (!date) return "-";
|
if (!date) return "-";
|
||||||
return date.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" });
|
|
||||||
|
// 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) => {
|
const formatPrice = (price) => {
|
||||||
@@ -56,6 +83,15 @@ const formatPrice = (price) => {
|
|||||||
}).format(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) => {
|
const calculateTrend = (snapshots) => {
|
||||||
if (!snapshots || snapshots.length < 2) {
|
if (!snapshots || snapshots.length < 2) {
|
||||||
return { direction: "stable", percent: 0, color: COLORS.yellow, arrow: "→" };
|
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: "→" };
|
return { direction: "stable", percent: percentChange, color: COLORS.yellow, arrow: "→" };
|
||||||
};
|
};
|
||||||
|
|
||||||
const PriceChart = ({ productId }) => {
|
const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
|
||||||
const [snapshots, setSnapshots] = useState([]);
|
const [snapshots, setSnapshots] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState(30);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSnapshots = async () => {
|
const fetchSnapshots = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await api.fetchSnapshots(productId, 30);
|
const data = await api.fetchSnapshots(productId, selectedPeriod);
|
||||||
setSnapshots(data);
|
setSnapshots(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -101,7 +138,7 @@ const PriceChart = ({ productId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchSnapshots();
|
fetchSnapshots();
|
||||||
}, [productId]);
|
}, [productId, selectedPeriod]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -126,7 +163,18 @@ const PriceChart = ({ productId }) => {
|
|||||||
|
|
||||||
// Préparer les données (inverser pour ordre chronologique)
|
// Préparer les données (inverser pour ordre chronologique)
|
||||||
const sortedSnapshots = [...snapshots].reverse();
|
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);
|
const prices = sortedSnapshots.map((s) => s.prix_actuel);
|
||||||
|
|
||||||
// Calculer min/max
|
// Calculer min/max
|
||||||
@@ -137,6 +185,49 @@ const PriceChart = ({ productId }) => {
|
|||||||
// Calculer la tendance
|
// Calculer la tendance
|
||||||
const trend = calculateTrend(snapshots);
|
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 = {
|
const chartData = {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
@@ -172,6 +263,9 @@ const PriceChart = ({ productId }) => {
|
|||||||
label: (context) => formatPrice(context.raw),
|
label: (context) => formatPrice(context.raw),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
annotation: {
|
||||||
|
annotations,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
@@ -182,7 +276,7 @@ const PriceChart = ({ productId }) => {
|
|||||||
color: COLORS.muted,
|
color: COLORS.muted,
|
||||||
font: { size: 10 },
|
font: { size: 10 },
|
||||||
maxRotation: 0,
|
maxRotation: 0,
|
||||||
maxTicksLimit: 5,
|
maxTicksLimit: selectedPeriod <= 7 ? 7 : 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
@@ -192,8 +286,10 @@ const PriceChart = ({ productId }) => {
|
|||||||
ticks: {
|
ticks: {
|
||||||
color: COLORS.muted,
|
color: COLORS.muted,
|
||||||
font: { size: 10 },
|
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 (
|
return (
|
||||||
<div className="price-chart-container">
|
<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">
|
<div className="price-chart">
|
||||||
<Line data={chartData} options={chartOptions} />
|
<Line data={chartData} options={chartOptions} />
|
||||||
</div>
|
</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 React, { useState } from "react";
|
||||||
import useProductStore from "../../stores/useProductStore";
|
import useProductStore from "../../stores/useProductStore";
|
||||||
|
import useConfigStore from "../../stores/useConfigStore";
|
||||||
import PriceChart from "../charts/PriceChart";
|
import PriceChart from "../charts/PriceChart";
|
||||||
import Lightbox from "../common/Lightbox";
|
import Lightbox from "../common/Lightbox";
|
||||||
import ProductDetailModal from "./ProductDetailModal";
|
import ProductDetailModal from "./ProductDetailModal";
|
||||||
@@ -19,6 +20,7 @@ const formatNumber = (num) => {
|
|||||||
|
|
||||||
const ProductCard = ({ product }) => {
|
const ProductCard = ({ product }) => {
|
||||||
const { scrapeProduct, deleteProduct, scraping } = useProductStore();
|
const { scrapeProduct, deleteProduct, scraping } = useProductStore();
|
||||||
|
const imageRatio = useConfigStore((state) => state.config.ui?.image_ratio || 40);
|
||||||
const isScraping = scraping[product.id];
|
const isScraping = scraping[product.id];
|
||||||
const [showLightbox, setShowLightbox] = useState(false);
|
const [showLightbox, setShowLightbox] = useState(false);
|
||||||
const [showDetail, setShowDetail] = useState(false);
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
@@ -52,11 +54,22 @@ const ProductCard = ({ product }) => {
|
|||||||
<span className="boutique">
|
<span className="boutique">
|
||||||
<i className="fa-brands fa-amazon"></i> {product.boutique}
|
<i className="fa-brands fa-amazon"></i> {product.boutique}
|
||||||
</span>
|
</span>
|
||||||
{product.actif ? (
|
<div className="header-right">
|
||||||
<span className="status active">Actif</span>
|
<a
|
||||||
) : (
|
href={product.url}
|
||||||
<span className="status inactive">Inactif</span>
|
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>
|
</div>
|
||||||
|
|
||||||
<h3
|
<h3
|
||||||
@@ -67,7 +80,7 @@ const ProductCard = ({ product }) => {
|
|||||||
{product.titre || "Titre non disponible"}
|
{product.titre || "Titre non disponible"}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="product-body">
|
<div className="product-body" style={{ "--image-ratio": imageRatio }}>
|
||||||
<div
|
<div
|
||||||
className={`product-image ${product.url_image ? "clickable" : ""}`}
|
className={`product-image ${product.url_image ? "clickable" : ""}`}
|
||||||
onClick={() => product.url_image && setShowLightbox(true)}
|
onClick={() => product.url_image && setShowLightbox(true)}
|
||||||
@@ -169,22 +182,21 @@ const ProductCard = ({ product }) => {
|
|||||||
{/* Méta */}
|
{/* Méta */}
|
||||||
<div className="product-meta">
|
<div className="product-meta">
|
||||||
<span className="asin">Ref: {product.asin}</span>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Graphique historique 30j */}
|
{/* Graphique historique */}
|
||||||
<PriceChart productId={product.id} />
|
<PriceChart
|
||||||
|
productId={product.id}
|
||||||
|
prixConseille={product.prix_conseille}
|
||||||
|
prixMin30j={product.prix_min_30j}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="product-actions">
|
<div className="product-actions">
|
||||||
<button
|
<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 * 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 SettingsPage = () => {
|
||||||
const [frontendConfig, setFrontendConfig] = useState(null);
|
const [frontendConfig, setFrontendConfig] = useState(null);
|
||||||
const [backendConfig, setBackendConfig] = useState(null);
|
const [backendConfig, setBackendConfig] = useState(null);
|
||||||
|
const [dbInfo, setDbInfo] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [success, setSuccess] = useState(null);
|
const [success, setSuccess] = useState(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
@@ -17,12 +38,14 @@ const SettingsPage = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [fe, be] = await Promise.all([
|
const [fe, be, db] = await Promise.all([
|
||||||
api.fetchFrontendConfig(),
|
api.fetchFrontendConfig(),
|
||||||
api.fetchBackendConfig(),
|
api.fetchBackendConfig(),
|
||||||
|
api.fetchDatabaseInfo().catch(() => null),
|
||||||
]);
|
]);
|
||||||
setFrontendConfig(fe);
|
setFrontendConfig(fe);
|
||||||
setBackendConfig(be);
|
setBackendConfig(be);
|
||||||
|
setDbInfo(db);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Erreur lors du chargement des configurations: " + err.message);
|
setError("Erreur lors du chargement des configurations: " + err.message);
|
||||||
} finally {
|
} 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="settings-page">
|
<main className="settings-page">
|
||||||
@@ -156,6 +220,31 @@ const SettingsPage = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="form-group">
|
||||||
<label>Rafraîchissement auto (secondes)</label>
|
<label>Rafraîchissement auto (secondes)</label>
|
||||||
<input
|
<input
|
||||||
@@ -401,6 +490,67 @@ const SettingsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</main>
|
</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",
|
button_mode: "text/icon",
|
||||||
columns_desktop: 3,
|
columns_desktop: 3,
|
||||||
card_density: "comfortable",
|
card_density: "comfortable",
|
||||||
|
image_ratio: 40, // % de la hauteur du body pour l'image (20-60)
|
||||||
show_fields: {
|
show_fields: {
|
||||||
price: true,
|
price: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
@@ -49,6 +50,24 @@ const useConfigStore = create((set) => ({
|
|||||||
const state = useConfigStore.getState();
|
const state = useConfigStore.getState();
|
||||||
return state.config.ui?.show_fields || DEFAULT_CONFIG.ui.show_fields;
|
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;
|
export default useConfigStore;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import * as api from "../api/client";
|
import * as api from "../api/client";
|
||||||
|
import useCommandLogStore from "./useCommandLogStore";
|
||||||
|
|
||||||
const useProductStore = create((set, get) => ({
|
const useProductStore = create((set, get) => ({
|
||||||
// State
|
// State
|
||||||
@@ -11,46 +12,58 @@ const useProductStore = create((set, get) => ({
|
|||||||
// Actions
|
// Actions
|
||||||
fetchProducts: async () => {
|
fetchProducts: async () => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
|
useCommandLogStore.getState().logInfo("Chargement des produits...");
|
||||||
try {
|
try {
|
||||||
const products = await api.fetchProducts();
|
const products = await api.fetchProducts();
|
||||||
set({ products, loading: false });
|
set({ products, loading: false });
|
||||||
|
useCommandLogStore.getState().logSuccess(`${products.length} produits chargés`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: err.message, loading: false });
|
set({ error: err.message, loading: false });
|
||||||
|
useCommandLogStore.getState().logError(`Erreur: ${err.message}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addProduct: async (data) => {
|
addProduct: async (data) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
|
useCommandLogStore.getState().logInfo("Ajout du produit...");
|
||||||
try {
|
try {
|
||||||
const newProduct = await api.createProduct(data);
|
const newProduct = await api.createProduct(data);
|
||||||
// Rafraîchir la liste complète pour avoir les données enrichies (ProductWithSnapshot)
|
// Rafraîchir la liste complète pour avoir les données enrichies (ProductWithSnapshot)
|
||||||
await get().fetchProducts();
|
await get().fetchProducts();
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
|
useCommandLogStore.getState().logSuccess(`Produit ajouté: ${newProduct.titre || newProduct.asin}`);
|
||||||
return newProduct;
|
return newProduct;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: err.message, loading: false });
|
set({ error: err.message, loading: false });
|
||||||
|
useCommandLogStore.getState().logError(`Erreur ajout: ${err.message}`);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteProduct: async (id) => {
|
deleteProduct: async (id) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
|
useCommandLogStore.getState().logInfo(`Suppression du produit #${id}...`);
|
||||||
try {
|
try {
|
||||||
await api.deleteProduct(id);
|
await api.deleteProduct(id);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
products: state.products.filter((p) => p.id !== id),
|
products: state.products.filter((p) => p.id !== id),
|
||||||
}));
|
}));
|
||||||
|
useCommandLogStore.getState().logSuccess(`Produit #${id} supprimé`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: err.message });
|
set({ error: err.message });
|
||||||
|
useCommandLogStore.getState().logError(`Erreur suppression: ${err.message}`);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
scrapeProduct: async (id) => {
|
scrapeProduct: async (id) => {
|
||||||
|
const product = get().products.find((p) => p.id === id);
|
||||||
|
const productName = product?.titre?.substring(0, 30) || `#${id}`;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
scraping: { ...state.scraping, [id]: true },
|
scraping: { ...state.scraping, [id]: true },
|
||||||
error: null,
|
error: null,
|
||||||
}));
|
}));
|
||||||
|
useCommandLogStore.getState().logInfo(`Scraping: ${productName}...`);
|
||||||
try {
|
try {
|
||||||
const result = await api.scrapeProduct(id);
|
const result = await api.scrapeProduct(id);
|
||||||
// Refresh la liste pour avoir les nouvelles données
|
// Refresh la liste pour avoir les nouvelles données
|
||||||
@@ -59,25 +72,48 @@ const useProductStore = create((set, get) => ({
|
|||||||
const { [id]: _, ...rest } = state.scraping;
|
const { [id]: _, ...rest } = state.scraping;
|
||||||
return { scraping: rest };
|
return { scraping: rest };
|
||||||
});
|
});
|
||||||
|
const status = result.status === "success" ? "logSuccess" : "logWarning";
|
||||||
|
useCommandLogStore.getState()[status](`Scrape ${productName}: ${result.status}`);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const { [id]: _, ...rest } = state.scraping;
|
const { [id]: _, ...rest } = state.scraping;
|
||||||
return { scraping: rest, error: err.message };
|
return { scraping: rest, error: err.message };
|
||||||
});
|
});
|
||||||
|
useCommandLogStore.getState().logError(`Erreur scrape: ${err.message}`);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
scrapeAll: async () => {
|
scrapeAll: async () => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
|
useCommandLogStore.getState().logInfo("Scraping de tous les produits...");
|
||||||
try {
|
try {
|
||||||
const result = await api.scrapeAll();
|
const result = await api.scrapeAll();
|
||||||
await get().fetchProducts();
|
await get().fetchProducts();
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
|
useCommandLogStore.getState().logSuccess(
|
||||||
|
`Scrape terminé: ${result.success}/${result.total} réussis`
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: err.message, loading: false });
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -248,6 +248,33 @@ a {
|
|||||||
color: $accent;
|
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 {
|
.status {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -287,6 +314,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-body {
|
.product-body {
|
||||||
|
--image-ratio: 40; // Valeur par défaut, override par CSS variable
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -294,9 +322,12 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-image {
|
.product-image {
|
||||||
flex-shrink: 0;
|
// Taille basée sur le ratio (variable CSS --image-ratio en %)
|
||||||
width: 120px;
|
// flex-basis utilise le ratio pour le partage horizontal
|
||||||
height: 120px;
|
flex: 0 0 calc(var(--image-ratio) * 1%);
|
||||||
|
max-width: calc(var(--image-ratio) * 1%);
|
||||||
|
min-width: 60px;
|
||||||
|
aspect-ratio: 1;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -305,6 +336,7 @@ a {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: flex-basis 0.2s ease, max-width 0.2s ease;
|
||||||
|
|
||||||
&.clickable {
|
&.clickable {
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
@@ -340,11 +372,14 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-info {
|
.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;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 200px; // Limite pour éviter débordement
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-title {
|
.product-title {
|
||||||
@@ -543,6 +578,34 @@ a {
|
|||||||
color: $accent-red;
|
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 {
|
.price-chart-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1614,12 +1677,20 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slider-value {
|
.slider-value {
|
||||||
min-width: 30px;
|
min-width: 40px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: $accent;
|
color: $accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slider-label-side {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
min-width: 35px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-group {
|
.checkbox-group {
|
||||||
@@ -1687,3 +1758,293 @@ a {
|
|||||||
background: $text-muted;
|
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