claude
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import DebugPage from "./pages/DebugPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import useProductStore from "./stores/useProductStore";
|
||||
import AddProductModal from "./components/products/AddProductModal";
|
||||
|
||||
@@ -35,6 +36,9 @@ const Header = () => {
|
||||
<NavLink to="/debug" className={({ isActive }) => isActive ? "active" : ""}>
|
||||
<i className="fa-solid fa-bug"></i> Debug
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={({ isActive }) => isActive ? "active" : ""}>
|
||||
<i className="fa-solid fa-cog"></i> Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
|
||||
@@ -63,6 +67,7 @@ const App = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/debug" element={<DebugPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -80,3 +80,33 @@ export const fetchDebugLogs = async (lines = 100) => {
|
||||
const response = await fetch(`${BASE_URL}/debug/logs?lines=${lines}`);
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
// Config frontend (via API backend)
|
||||
export const fetchFrontendConfig = async () => {
|
||||
const response = await fetch(`${BASE_URL}/config/frontend`);
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
export const updateFrontendConfig = async (config) => {
|
||||
const response = await fetch(`${BASE_URL}/config/frontend`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
// Config backend
|
||||
export const fetchBackendConfig = async () => {
|
||||
const response = await fetch(`${BASE_URL}/config/backend`);
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
export const updateBackendConfig = async (config) => {
|
||||
const response = await fetch(`${BASE_URL}/config/backend`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
232
frontend/src/components/charts/PriceChart.jsx
Normal file
232
frontend/src/components/charts/PriceChart.jsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import * as api from "../../api/client";
|
||||
|
||||
// Enregistrer les composants Chart.js
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Filler
|
||||
);
|
||||
|
||||
// Couleurs Gruvbox
|
||||
const COLORS = {
|
||||
green: "#b8bb26",
|
||||
yellow: "#fabd2f",
|
||||
red: "#fb4934",
|
||||
orange: "#fe8019",
|
||||
text: "#ebdbb2",
|
||||
muted: "#928374",
|
||||
bg: "#282828",
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" });
|
||||
};
|
||||
|
||||
const formatPrice = (price) => {
|
||||
if (price == null) return "-";
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const calculateTrend = (snapshots) => {
|
||||
if (!snapshots || snapshots.length < 2) {
|
||||
return { direction: "stable", percent: 0, color: COLORS.yellow, arrow: "→" };
|
||||
}
|
||||
|
||||
const prices = snapshots
|
||||
.filter((s) => s.prix_actuel != null)
|
||||
.map((s) => s.prix_actuel);
|
||||
|
||||
if (prices.length < 2) {
|
||||
return { direction: "stable", percent: 0, color: COLORS.yellow, arrow: "→" };
|
||||
}
|
||||
|
||||
const first = prices[prices.length - 1]; // Le plus ancien
|
||||
const last = prices[0]; // Le plus récent
|
||||
const percentChange = ((last - first) / first) * 100;
|
||||
|
||||
if (percentChange < -2) {
|
||||
return { direction: "down", percent: percentChange, color: COLORS.green, arrow: "↓" };
|
||||
} else if (percentChange > 2) {
|
||||
return { direction: "up", percent: percentChange, color: COLORS.red, arrow: "↑" };
|
||||
}
|
||||
return { direction: "stable", percent: percentChange, color: COLORS.yellow, arrow: "→" };
|
||||
};
|
||||
|
||||
const PriceChart = ({ productId }) => {
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSnapshots = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.fetchSnapshots(productId, 30);
|
||||
setSnapshots(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSnapshots();
|
||||
}, [productId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="price-chart-loading">
|
||||
<i className="fa-solid fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="price-chart-error">Erreur: {error}</div>;
|
||||
}
|
||||
|
||||
if (!snapshots || snapshots.length === 0) {
|
||||
return (
|
||||
<div className="price-chart-empty">
|
||||
<i className="fa-solid fa-chart-line"></i>
|
||||
<span>Pas encore de données</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Préparer les données (inverser pour ordre chronologique)
|
||||
const sortedSnapshots = [...snapshots].reverse();
|
||||
const labels = sortedSnapshots.map((s) => formatDate(s.scrape_le));
|
||||
const prices = sortedSnapshots.map((s) => s.prix_actuel);
|
||||
|
||||
// Calculer min/max
|
||||
const validPrices = prices.filter((p) => p != null);
|
||||
const minPrice = Math.min(...validPrices);
|
||||
const maxPrice = Math.max(...validPrices);
|
||||
|
||||
// Calculer la tendance
|
||||
const trend = calculateTrend(snapshots);
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: prices,
|
||||
borderColor: trend.color,
|
||||
backgroundColor: `${trend.color}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: trend.color,
|
||||
pointBorderColor: trend.color,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: COLORS.bg,
|
||||
titleColor: COLORS.text,
|
||||
bodyColor: COLORS.text,
|
||||
borderColor: COLORS.muted,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: (context) => formatPrice(context.raw),
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: COLORS.muted,
|
||||
font: { size: 10 },
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 5,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: `${COLORS.muted}30`,
|
||||
},
|
||||
ticks: {
|
||||
color: COLORS.muted,
|
||||
font: { size: 10 },
|
||||
callback: (value) => formatPrice(value),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lastSnapshot = snapshots[0];
|
||||
const lastDate = lastSnapshot
|
||||
? new Date(lastSnapshot.scrape_le).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<div className="price-chart-container">
|
||||
<div className="price-chart">
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
<div className="price-chart-stats">
|
||||
<span className="stat">
|
||||
<span className="label">Min</span>
|
||||
<span className="value">{formatPrice(minPrice)}</span>
|
||||
</span>
|
||||
<span className="stat">
|
||||
<span className="label">Max</span>
|
||||
<span className="value">{formatPrice(maxPrice)}</span>
|
||||
</span>
|
||||
<span className="stat trend" style={{ color: trend.color }}>
|
||||
<span className="label">Tendance</span>
|
||||
<span className="value">
|
||||
{trend.arrow} {trend.percent >= 0 ? "+" : ""}
|
||||
{trend.percent.toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
<span className="stat">
|
||||
<span className="label">Dernier</span>
|
||||
<span className="value">{lastDate}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceChart;
|
||||
@@ -1,5 +1,19 @@
|
||||
import React from "react";
|
||||
import useProductStore from "../../stores/useProductStore";
|
||||
import PriceChart from "../charts/PriceChart";
|
||||
|
||||
const formatPrice = (price) => {
|
||||
if (price == null) return null;
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num == null) return null;
|
||||
return new Intl.NumberFormat("fr-FR").format(num);
|
||||
};
|
||||
|
||||
const ProductCard = ({ product }) => {
|
||||
const { scrapeProduct, deleteProduct, scraping } = useProductStore();
|
||||
@@ -22,10 +36,18 @@ const ProductCard = ({ product }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasBadges =
|
||||
product.prime ||
|
||||
product.choix_amazon ||
|
||||
product.offre_limitee ||
|
||||
product.exclusivite_amazon;
|
||||
|
||||
return (
|
||||
<article className="product-card">
|
||||
<div className="product-header">
|
||||
<span className="boutique">{product.boutique}</span>
|
||||
<span className="boutique">
|
||||
<i className="fa-brands fa-amazon"></i> {product.boutique}
|
||||
</span>
|
||||
{product.actif ? (
|
||||
<span className="status active">Actif</span>
|
||||
) : (
|
||||
@@ -33,10 +55,14 @@ const ProductCard = ({ product }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="product-title" title={product.titre}>
|
||||
{product.titre || "Titre non disponible"}
|
||||
</h3>
|
||||
|
||||
<div className="product-body">
|
||||
<div className="product-image">
|
||||
{product.url_image ? (
|
||||
<img src={product.url_image} alt={product.titre} />
|
||||
<img src={product.url_image} alt={product.titre} loading="lazy" />
|
||||
) : (
|
||||
<div className="no-image">
|
||||
<i className="fa-solid fa-image"></i>
|
||||
@@ -45,15 +71,89 @@ const ProductCard = ({ product }) => {
|
||||
</div>
|
||||
|
||||
<div className="product-info">
|
||||
<h3 className="product-title" title={product.titre}>
|
||||
{product.titre || "Titre non disponible"}
|
||||
</h3>
|
||||
|
||||
<div className="product-meta">
|
||||
<span className="asin">ASIN: {product.asin}</span>
|
||||
{product.categorie && (
|
||||
<span className="category">{product.categorie}</span>
|
||||
{/* Section Prix */}
|
||||
<div className="price-section">
|
||||
{product.prix_actuel != null && (
|
||||
<div className="price-row price-current">
|
||||
<span className="price-label">Actuel</span>
|
||||
<span className="price-value">{formatPrice(product.prix_actuel)}</span>
|
||||
</div>
|
||||
)}
|
||||
{product.prix_conseille != null && (
|
||||
<div className="price-row price-list">
|
||||
<span className="price-label">Prix conseillé</span>
|
||||
<span className="price-value strikethrough">
|
||||
{formatPrice(product.prix_conseille)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{product.reduction_pourcent != null && product.reduction_pourcent !== 0 && (
|
||||
<div className="price-row price-discount">
|
||||
<span className="price-label">Réduction</span>
|
||||
<span className="price-value discount">-{product.reduction_pourcent}%</span>
|
||||
</div>
|
||||
)}
|
||||
{product.prix_min_30j != null && (
|
||||
<div className="price-row price-min">
|
||||
<span className="price-label">Min 30j</span>
|
||||
<span className="price-value">{formatPrice(product.prix_min_30j)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stock et Note */}
|
||||
<div className="product-stats">
|
||||
{product.etat_stock && (
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Stock</span>
|
||||
<span className={`stat-value ${product.en_stock ? "in-stock" : "out-of-stock"}`}>
|
||||
{product.etat_stock}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{product.note != null && (
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Note</span>
|
||||
<span className="stat-value rating">
|
||||
<i className="fa-solid fa-star"></i> {product.note.toFixed(1)}
|
||||
{product.nombre_avis != null && (
|
||||
<span className="review-count">({formatNumber(product.nombre_avis)})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
{hasBadges && (
|
||||
<div className="product-badges">
|
||||
{product.choix_amazon && (
|
||||
<span className="badge badge-choice">
|
||||
<i className="fa-solid fa-check-circle"></i> Choix Amazon
|
||||
</span>
|
||||
)}
|
||||
{product.prime && (
|
||||
<span className="badge badge-prime">
|
||||
<i className="fa-solid fa-truck-fast"></i> Prime
|
||||
</span>
|
||||
)}
|
||||
{product.offre_limitee && (
|
||||
<span className="badge badge-deal">
|
||||
<i className="fa-solid fa-bolt"></i> Offre limitée
|
||||
</span>
|
||||
)}
|
||||
{product.exclusivite_amazon && (
|
||||
<span className="badge badge-exclusive">
|
||||
<i className="fa-solid fa-gem"></i> Exclusivité
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Méta */}
|
||||
<div className="product-meta">
|
||||
<span className="asin">Ref: {product.asin}</span>
|
||||
{product.categorie && <span className="category">{product.categorie}</span>}
|
||||
</div>
|
||||
|
||||
<a
|
||||
@@ -67,6 +167,9 @@ const ProductCard = ({ product }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graphique historique 30j */}
|
||||
<PriceChart productId={product.id} />
|
||||
|
||||
<div className="product-actions">
|
||||
<button
|
||||
className="btn btn-scrape"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import ProductCard from "./ProductCard";
|
||||
|
||||
const ProductGrid = ({ products }) => {
|
||||
const ProductGrid = ({ products, columns = 3 }) => {
|
||||
if (!products || products.length === 0) {
|
||||
return (
|
||||
<section className="empty-state">
|
||||
@@ -12,8 +12,12 @@ const ProductGrid = ({ products }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const gridStyle = {
|
||||
"--grid-columns": columns,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-grid">
|
||||
<div className="product-grid" style={gridStyle}>
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React, { useEffect } from "react";
|
||||
import useProductStore from "../stores/useProductStore";
|
||||
import useConfigStore from "../stores/useConfigStore";
|
||||
import ProductGrid from "../components/products/ProductGrid";
|
||||
|
||||
const HomePage = () => {
|
||||
const { products, loading, error, fetchProducts, clearError } = useProductStore();
|
||||
const { config, fetchConfig } = useConfigStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchProducts();
|
||||
}, [fetchProducts]);
|
||||
}, [fetchProducts, fetchConfig]);
|
||||
|
||||
const columns = config.ui?.columns_desktop || 3;
|
||||
|
||||
return (
|
||||
<main className="home-page">
|
||||
@@ -26,7 +31,7 @@ const HomePage = () => {
|
||||
<p>Chargement des produits...</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProductGrid products={products} />
|
||||
<ProductGrid products={products} columns={columns} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
409
frontend/src/pages/SettingsPage.jsx
Normal file
409
frontend/src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as api from "../api/client";
|
||||
|
||||
const SettingsPage = () => {
|
||||
const [frontendConfig, setFrontendConfig] = useState(null);
|
||||
const [backendConfig, setBackendConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [fe, be] = await Promise.all([
|
||||
api.fetchFrontendConfig(),
|
||||
api.fetchBackendConfig(),
|
||||
]);
|
||||
setFrontendConfig(fe);
|
||||
setBackendConfig(be);
|
||||
} catch (err) {
|
||||
setError("Erreur lors du chargement des configurations: " + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveFrontendConfig = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const updated = await api.updateFrontendConfig(frontendConfig);
|
||||
setFrontendConfig(updated);
|
||||
setSuccess("Configuration frontend sauvegardée");
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setError("Erreur sauvegarde frontend: " + err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveBackendConfig = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const updated = await api.updateBackendConfig(backendConfig);
|
||||
setBackendConfig(updated);
|
||||
setSuccess("Configuration backend sauvegardée");
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setError("Erreur sauvegarde backend: " + err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="settings-page">
|
||||
<div className="loading-state">
|
||||
<i className="fa-solid fa-spinner fa-spin"></i>
|
||||
<p>Chargement des configurations...</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="settings-page">
|
||||
<h2>
|
||||
<i className="fa-solid fa-cog"></i> Paramètres
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="btn-close">
|
||||
<i className="fa-solid fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="success-banner">
|
||||
<i className="fa-solid fa-check"></i> {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-grid">
|
||||
{/* Section Frontend */}
|
||||
<section className="settings-section">
|
||||
<div className="section-header">
|
||||
<h3>
|
||||
<i className="fa-solid fa-desktop"></i> Configuration Frontend
|
||||
</h3>
|
||||
<span className="version-badge">v{frontendConfig?.versions?.frontend}</span>
|
||||
</div>
|
||||
|
||||
{frontendConfig && (
|
||||
<div className="settings-form">
|
||||
<div className="form-group">
|
||||
<label>Thème</label>
|
||||
<select
|
||||
value={frontendConfig.ui?.theme || "gruvbox_vintage_dark"}
|
||||
onChange={(e) =>
|
||||
setFrontendConfig({
|
||||
...frontendConfig,
|
||||
ui: { ...frontendConfig.ui, theme: e.target.value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="gruvbox_vintage_dark">Gruvbox Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Colonnes (desktop)</label>
|
||||
<div className="slider-group">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
value={frontendConfig.ui?.columns_desktop || 3}
|
||||
onChange={(e) =>
|
||||
setFrontendConfig({
|
||||
...frontendConfig,
|
||||
ui: { ...frontendConfig.ui, columns_desktop: parseInt(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="slider-value">{frontendConfig.ui?.columns_desktop || 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Densité des cartes</label>
|
||||
<select
|
||||
value={frontendConfig.ui?.card_density || "comfortable"}
|
||||
onChange={(e) =>
|
||||
setFrontendConfig({
|
||||
...frontendConfig,
|
||||
ui: { ...frontendConfig.ui, card_density: e.target.value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="comfortable">Confortable</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Rafraîchissement auto (secondes)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="3600"
|
||||
value={frontendConfig.ui?.refresh_auto_seconds || 300}
|
||||
onChange={(e) =>
|
||||
setFrontendConfig({
|
||||
...frontendConfig,
|
||||
ui: { ...frontendConfig.ui, refresh_auto_seconds: parseInt(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="form-hint">0 = désactivé</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Champs à afficher</label>
|
||||
<div className="checkbox-group">
|
||||
{["price", "stock", "ratings", "badges"].map((field) => (
|
||||
<label key={field} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={frontendConfig.ui?.show_fields?.[field] ?? true}
|
||||
onChange={(e) =>
|
||||
setFrontendConfig({
|
||||
...frontendConfig,
|
||||
ui: {
|
||||
...frontendConfig.ui,
|
||||
show_fields: {
|
||||
...frontendConfig.ui?.show_fields,
|
||||
[field]: e.target.checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
{field === "price" && "Prix"}
|
||||
{field === "stock" && "Stock"}
|
||||
{field === "ratings" && "Notes"}
|
||||
{field === "badges" && "Badges"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={saveFrontendConfig}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin"></i> Sauvegarde...</>
|
||||
) : (
|
||||
<><i className="fa-solid fa-save"></i> Sauvegarder Frontend</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section Backend */}
|
||||
<section className="settings-section">
|
||||
<div className="section-header">
|
||||
<h3>
|
||||
<i className="fa-solid fa-server"></i> Configuration Backend
|
||||
</h3>
|
||||
<span className="version-badge">v{backendConfig?.app?.version}</span>
|
||||
</div>
|
||||
|
||||
{backendConfig && (
|
||||
<div className="settings-form">
|
||||
<div className="form-group">
|
||||
<label>Environnement</label>
|
||||
<select
|
||||
value={backendConfig.app?.env || "dev"}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
app: { ...backendConfig.app, env: e.target.value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="dev">Développement</option>
|
||||
<option value="prod">Production</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Niveau de log</label>
|
||||
<select
|
||||
value={backendConfig.app?.log_level || "INFO"}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
app: { ...backendConfig.app, log_level: e.target.value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h4>Scraping</h4>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Intervalle scraping (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="1440"
|
||||
value={backendConfig.scrape?.interval_minutes || 60}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
scrape: { ...backendConfig.scrape, interval_minutes: parseInt(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Mode headless</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={backendConfig.scrape?.headless ?? true}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
scrape: { ...backendConfig.scrape, headless: e.target.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
Navigateur invisible
|
||||
</label>
|
||||
<span className="form-hint">Désactiver pour debug manuel</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Timeout (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5000"
|
||||
max="120000"
|
||||
step="1000"
|
||||
value={backendConfig.scrape?.timeout_ms || 30000}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
scrape: { ...backendConfig.scrape, timeout_ms: parseInt(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Délai entre pages (min-max ms)</label>
|
||||
<div className="range-inputs">
|
||||
<input
|
||||
type="number"
|
||||
min="500"
|
||||
max="10000"
|
||||
value={backendConfig.scrape?.delay_range_ms?.[0] || 1000}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
scrape: {
|
||||
...backendConfig.scrape,
|
||||
delay_range_ms: [parseInt(e.target.value), backendConfig.scrape?.delay_range_ms?.[1] || 3000],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1000"
|
||||
max="30000"
|
||||
value={backendConfig.scrape?.delay_range_ms?.[1] || 3000}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
scrape: {
|
||||
...backendConfig.scrape,
|
||||
delay_range_ms: [backendConfig.scrape?.delay_range_ms?.[0] || 1000, parseInt(e.target.value)],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>User Agent</label>
|
||||
<input
|
||||
type="text"
|
||||
value={backendConfig.scrape?.user_agent || ""}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
scrape: { ...backendConfig.scrape, user_agent: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4>Taxonomie</h4>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Catégories disponibles</label>
|
||||
<input
|
||||
type="text"
|
||||
value={backendConfig.taxonomy?.categories?.join(", ") || ""}
|
||||
onChange={(e) =>
|
||||
setBackendConfig({
|
||||
...backendConfig,
|
||||
taxonomy: {
|
||||
...backendConfig.taxonomy,
|
||||
categories: e.target.value.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="form-hint">Séparées par des virgules</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={saveBackendConfig}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin"></i> Sauvegarde...</>
|
||||
) : (
|
||||
<><i className="fa-solid fa-save"></i> Sauvegarder Backend</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
54
frontend/src/stores/useConfigStore.js
Normal file
54
frontend/src/stores/useConfigStore.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import * as api from "../api/client";
|
||||
|
||||
// Configuration par défaut
|
||||
const DEFAULT_CONFIG = {
|
||||
ui: {
|
||||
theme: "gruvbox_vintage_dark",
|
||||
button_mode: "text/icon",
|
||||
columns_desktop: 3,
|
||||
card_density: "comfortable",
|
||||
show_fields: {
|
||||
price: true,
|
||||
stock: true,
|
||||
ratings: true,
|
||||
badges: true,
|
||||
},
|
||||
refresh_auto_seconds: 300,
|
||||
},
|
||||
versions: {
|
||||
frontend: "0.1.0",
|
||||
backend_expected: "0.1.0",
|
||||
},
|
||||
};
|
||||
|
||||
const useConfigStore = create((set) => ({
|
||||
config: DEFAULT_CONFIG,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchConfig: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const config = await api.fetchFrontendConfig();
|
||||
set({ config, loading: false });
|
||||
} catch (err) {
|
||||
// En cas d'erreur, on garde la config par défaut
|
||||
console.warn("Impossible de charger config_frontend.json:", err.message);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Getters pratiques
|
||||
getColumns: () => {
|
||||
const state = useConfigStore.getState();
|
||||
return state.config.ui?.columns_desktop || 3;
|
||||
},
|
||||
|
||||
getShowFields: () => {
|
||||
const state = useConfigStore.getState();
|
||||
return state.config.ui?.show_fields || DEFAULT_CONFIG.ui.show_fields;
|
||||
},
|
||||
}));
|
||||
|
||||
export default useConfigStore;
|
||||
@@ -126,9 +126,18 @@ a {
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
--grid-columns: 3;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
||||
gap: 20px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -197,6 +206,8 @@ a {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
@@ -213,6 +224,9 @@ a {
|
||||
border-bottom: 1px solid $bg;
|
||||
|
||||
.boutique {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -236,22 +250,36 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
.product-card > .product-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
padding: 12px 16px 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: $bg;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
@@ -268,6 +296,9 @@ a {
|
||||
.product-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
@@ -281,11 +312,139 @@ a {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Section Prix
|
||||
.price-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
|
||||
.price-label {
|
||||
color: $text-muted;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-weight: 600;
|
||||
|
||||
&.strikethrough {
|
||||
text-decoration: line-through;
|
||||
color: $text-muted;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.discount {
|
||||
color: $accent-green;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price-current .price-value {
|
||||
font-size: 1.1rem;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
// Stats (Stock, Note)
|
||||
.product-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
|
||||
.stat-label {
|
||||
color: $text-muted;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
&.in-stock {
|
||||
color: $accent-green;
|
||||
}
|
||||
|
||||
&.out-of-stock {
|
||||
color: $accent-red;
|
||||
}
|
||||
|
||||
&.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: $accent-yellow;
|
||||
|
||||
i {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.review-count {
|
||||
color: $text-muted;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Badges
|
||||
.product-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
i {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-choice {
|
||||
background: rgba($accent-aqua, 0.15);
|
||||
color: $accent-aqua;
|
||||
}
|
||||
|
||||
.badge-prime {
|
||||
background: rgba(#00a8e1, 0.15);
|
||||
color: #00a8e1;
|
||||
}
|
||||
|
||||
.badge-deal {
|
||||
background: rgba($accent-red, 0.15);
|
||||
color: $accent-red;
|
||||
}
|
||||
|
||||
.badge-exclusive {
|
||||
background: rgba($accent-yellow, 0.15);
|
||||
color: $accent-yellow;
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
margin-top: auto;
|
||||
|
||||
.asin {
|
||||
font-size: 0.75rem;
|
||||
@@ -309,12 +468,69 @@ a {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Price Chart
|
||||
.price-chart-container {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid $bg;
|
||||
}
|
||||
|
||||
.price-chart {
|
||||
height: 120px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.price-chart-loading,
|
||||
.price-chart-error,
|
||||
.price-chart-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 80px;
|
||||
color: $text-muted;
|
||||
font-size: 0.85rem;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid $bg;
|
||||
}
|
||||
|
||||
.price-chart-error {
|
||||
color: $accent-red;
|
||||
}
|
||||
|
||||
.price-chart-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.label {
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.trend .value {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid $bg;
|
||||
background: $bg-soft;
|
||||
margin-top: auto;
|
||||
|
||||
.btn-scrape {
|
||||
flex: 1;
|
||||
@@ -574,6 +790,192 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
// Settings Page
|
||||
.settings-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 24px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: $text;
|
||||
|
||||
i {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: $card;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $bg;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
i {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 24px 0 16px 0;
|
||||
font-size: 0.9rem;
|
||||
color: $accent-yellow;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 0;
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
font-size: 0.75rem;
|
||||
background: $bg;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
color: $text-muted;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.slider-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: $bg;
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: $accent;
|
||||
cursor: pointer;
|
||||
border: 2px solid $card;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: $accent;
|
||||
cursor: pointer;
|
||||
border: 2px solid $card;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
input[type="number"] {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba($accent-green, 0.15);
|
||||
color: $accent-green;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Scrollbar styling
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
Reference in New Issue
Block a user