From b89e9e15dfa599874fd19602f1271f05489a14da Mon Sep 17 00:00:00 2001 From: gilles Date: Tue, 20 Jan 2026 22:36:42 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20am=C3=A9lioration=20UI=20-=20popup=20lo?= =?UTF-8?q?gs,=20ratio=20image,=20lien=20Amazon=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/config_frontend.json | 3 +- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/App.jsx | 2 + frontend/src/components/charts/PriceChart.jsx | 127 +++++- .../src/components/common/CommandLogPopup.jsx | 49 +++ .../src/components/products/ProductCard.jsx | 48 ++- frontend/src/pages/SettingsPage.jsx | 154 +++++++- frontend/src/stores/useCommandLogStore.js | 65 +++ frontend/src/stores/useConfigStore.js | 19 + frontend/src/stores/useProductStore.js | 36 ++ frontend/src/styles/global.scss | 371 +++++++++++++++++- 12 files changed, 850 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/common/CommandLogPopup.jsx create mode 100644 frontend/src/stores/useCommandLogStore.js 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; } + } +}