diff --git a/frontend/config_frontend.json b/frontend/config_frontend.json
index 58de525..2cdb2ac 100644
--- a/frontend/config_frontend.json
+++ b/frontend/config_frontend.json
@@ -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,
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6d050e3..14f3604 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index c594717..cb8b9bb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 3b04a72..61a5da2 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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 = () => (
} />
} />
+
);
diff --git a/frontend/src/components/charts/PriceChart.jsx b/frontend/src/components/charts/PriceChart.jsx
index aedf7dd..fc60175 100644
--- a/frontend/src/components/charts/PriceChart.jsx
+++ b/frontend/src/components/charts/PriceChart.jsx
@@ -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 "-";
- 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) => {
@@ -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 (
+ {/* Sélecteur de période */}
+
+ {PERIODS.map((p) => (
+
+ ))}
+
+
diff --git a/frontend/src/components/common/CommandLogPopup.jsx b/frontend/src/components/common/CommandLogPopup.jsx
new file mode 100644
index 0000000..6deec8c
--- /dev/null
+++ b/frontend/src/components/common/CommandLogPopup.jsx
@@ -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 (
+
+
+
+ Logs
+
+
+
+
+ {displayLogs.map((log) => (
+
+ {log.timestamp}
+
+ {log.message}
+
+ ))}
+
+
+ );
+};
+
+export default CommandLogPopup;
diff --git a/frontend/src/components/products/ProductCard.jsx b/frontend/src/components/products/ProductCard.jsx
index e5fc86b..dd389aa 100644
--- a/frontend/src/components/products/ProductCard.jsx
+++ b/frontend/src/components/products/ProductCard.jsx
@@ -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,11 +54,22 @@ const ProductCard = ({ product }) => {
{product.boutique}
- {product.actif ? (
-
Actif
- ) : (
-
Inactif
- )}
+
+
+
+
+ {product.actif ? (
+
Actif
+ ) : (
+
Inactif
+ )}
+
{
{product.titre || "Titre non disponible"}
-
+
product.url_image && setShowLightbox(true)}
@@ -169,22 +182,21 @@ const ProductCard = ({ product }) => {
{/* Méta */}
Ref: {product.asin}
- {product.categorie && {product.categorie}}
+ {product.categorie_amazon && (
+
+ {product.categorie_amazon.split(" > ").slice(-1)[0]}
+
+ )}
-
-
- Voir sur Amazon
-
- {/* Graphique historique 30j */}
-
+ {/* Graphique historique */}
+
+
+
+
+ Info
+ {
+ 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);
+ }}
+ />
+ Image
+ {frontendConfig.ui?.image_ratio || 40}%
+
+
Proportion de l'image dans la vignette produit
+
+
{
)}
+
+ {/* Section Database Backup */}
+
+
+
+ Base de données
+
+
+
+
+ {dbInfo && (
+
+
+ Fichier
+ {dbInfo.filename}
+
+
+ Taille
+ {formatSize(dbInfo.size_bytes)}
+
+
+ Dernière modification
+ {formatDate(dbInfo.modified_at)}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ La restauration créera automatiquement une sauvegarde de la base actuelle avant de la remplacer.
+
+
+
);
diff --git a/frontend/src/stores/useCommandLogStore.js b/frontend/src/stores/useCommandLogStore.js
new file mode 100644
index 0000000..004222d
--- /dev/null
+++ b/frontend/src/stores/useCommandLogStore.js
@@ -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;
diff --git a/frontend/src/stores/useConfigStore.js b/frontend/src/stores/useConfigStore.js
index 6e593bd..6a2e1b7 100644
--- a/frontend/src/stores/useConfigStore.js
+++ b/frontend/src/stores/useConfigStore.js
@@ -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;
diff --git a/frontend/src/stores/useProductStore.js b/frontend/src/stores/useProductStore.js
index 3f7fedc..702e11f 100644
--- a/frontend/src/stores/useProductStore.js
+++ b/frontend/src/stores/useProductStore.js
@@ -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;
}
},
diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss
index 9b30f59..1e7bcc7 100644
--- a/frontend/src/styles/global.scss
+++ b/frontend/src/styles/global.scss
@@ -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; }
+ }
+}