This commit is contained in:
2026-01-25 14:48:26 +01:00
parent 5c3e6b84a4
commit c56a4632a2
958 changed files with 1149102 additions and 123 deletions

View File

@@ -1,20 +0,0 @@
{
"ui": {
"theme": "gruvbox_vintage_dark",
"button_mode": "text/icon",
"columns_desktop": 4,
"card_density": "comfortable",
"image_ratio": 43,
"show_fields": {
"price": true,
"stock": true,
"ratings": true,
"badges": true
},
"refresh_auto_seconds": 300
},
"versions": {
"frontend": "0.1.0",
"backend_expected": "0.1.0"
}
}

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>suivi_produits</title>
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,16 +1,18 @@
{
"name": "suivi_produit_frontend",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "suivi_produit_frontend",
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"date-fns": "^3.6.0",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^18.3.1",
@@ -1587,6 +1589,16 @@
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
},
"node_modules/chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
@@ -1619,6 +1631,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "suivi_produit_frontend",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "vite",
@@ -10,7 +10,9 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"date-fns": "^3.6.0",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^18.3.1",

View File

@@ -2,15 +2,16 @@
"ui": {
"theme": "gruvbox_vintage_dark",
"button_mode": "text/icon",
"columns_desktop": 3,
"columns_desktop": 4,
"card_density": "comfortable",
"image_ratio": 46,
"show_fields": {
"price": true,
"stock": true,
"ratings": true,
"badges": true
},
"refresh_auto_seconds": 300
"refresh_auto_seconds": 60
},
"versions": {
"frontend": "0.1.0",

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

68
frontend/public/store.svg Normal file
View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<rect x="47.421" y="138.055" style="fill:#EBEBEC;" width="417.427" height="365.248"/>
<rect x="47.421" y="138.055" style="fill:#D7D8D9;" width="26.089" height="365.248"/>
<path style="fill:#CF442B;" d="M47.421,242.412c19.211,0,34.786-15.574,34.786-34.786H12.636
C12.636,226.838,28.209,242.412,47.421,242.412L47.421,242.412z"/>
<path style="fill:#FDD042;" d="M116.993,242.412c19.211,0,34.786-15.574,34.786-34.786H82.207
C82.207,226.838,97.78,242.412,116.993,242.412L116.993,242.412z"/>
<path style="fill:#CF442B;" d="M186.564,242.412c19.211,0,34.786-15.574,34.786-34.786h-69.571
C151.778,226.838,167.351,242.412,186.564,242.412L186.564,242.412z"/>
<path style="fill:#FDD042;" d="M256.135,242.412c19.211,0,34.786-15.574,34.786-34.786h-69.571
C221.349,226.838,236.922,242.412,256.135,242.412L256.135,242.412z"/>
<path style="fill:#CF442B;" d="M325.706,242.412c19.211,0,34.786-15.574,34.786-34.786H290.92
C290.92,226.838,306.493,242.412,325.706,242.412L325.706,242.412z"/>
<path style="fill:#FDD042;" d="M395.277,242.412L395.277,242.412c19.211,0,34.786-15.574,34.786-34.786h-69.571
C360.491,226.838,376.065,242.412,395.277,242.412z"/>
<path style="fill:#CF442B;" d="M464.848,242.412c19.211,0,34.786-15.574,34.786-34.786h-69.571
C430.063,226.838,445.636,242.412,464.848,242.412L464.848,242.412z"/>
<polygon style="fill:#E5563C;" points="56.118,7.609 108.296,7.609 82.207,207.626 12.636,207.626 "/>
<polygon style="fill:#FDDD85;" points="108.296,7.609 169.171,7.609 151.778,207.626 82.207,207.626 "/>
<polygon style="fill:#E5563C;" points="169.171,7.609 221.349,7.609 221.349,207.626 151.778,207.626 "/>
<rect x="221.345" y="7.609" style="fill:#FDDD85;" width="69.571" height="200.017"/>
<polygon style="fill:#E5563C;" points="290.92,7.609 343.099,7.609 360.491,207.626 290.92,207.626 "/>
<polygon style="fill:#FDDD85;" points="343.099,7.609 403.973,7.609 430.063,207.626 360.491,207.626 "/>
<polygon style="fill:#E5563C;" points="403.973,7.609 456.152,7.609 499.634,207.626 430.063,207.626 "/>
<rect x="82.203" y="268.501" style="fill:#74757B;" width="139.142" height="234.803"/>
<rect x="82.203" y="268.501" style="fill:#606268;" width="26.089" height="234.803"/>
<rect x="273.523" y="268.501" style="fill:#AFF0E8;" width="139.142" height="104.357"/>
<g>
<polygon style="fill:#74DBC9;" points="387.668,268.501 283.311,372.858 355.056,372.858 412.67,315.244 412.67,268.501 "/>
<polygon style="fill:#74DBC9;" points="273.528,268.501 273.528,334.811 339.838,268.501 "/>
<polygon style="fill:#74DBC9;" points="380.602,372.858 412.67,372.858 412.67,340.79 "/>
</g>
<circle style="fill:#FDD042;" cx="177.863" cy="385.902" r="17.393"/>
<path d="M4.484,207.686c0,0.023-0.271,0.046-0.271,0.068c0.062,20.831,14.944,38.211,34.511,42.024v175.259
c0,4.504,3.65,8.153,8.153,8.153s8.153-3.649,8.153-8.153V249.779c11.958-2.106,20.619-8.346,26.904-17.024
c7.808,10.779,20.623,17.81,34.921,17.81s27.045-7.031,34.854-17.81c7.808,10.779,20.521,17.81,34.819,17.81
c14.298,0,26.995-7.031,34.803-17.81c7.808,10.779,20.496,17.81,34.794,17.81c14.298,0,26.982-7.031,34.79-17.81
c7.808,10.779,20.49,17.81,34.788,17.81s26.978-7.031,34.787-17.81c7.808,10.779,20.489,17.81,34.786,17.81
s26.706-7.031,34.514-17.81c6.285,8.677,15.49,14.919,26.361,17.024v36.117c0,4.504,3.649,8.153,8.153,8.153
c4.504,0,8.153-3.649,8.153-8.153v-36.117c20.654-3.812,34.991-21.191,35.054-42.021c0.01-0.625,0.087-1.252-0.047-1.863
L464.05,6.149C463.235,2.401,459.987,0,456.152,0H56.118c-3.835,0-7.152,2.401-7.967,6.149L4.669,206.031
C4.534,206.615,4.484,207.088,4.484,207.686z M47.421,234.803c-11.843,0-21.905-8.696-25.357-18.48h50.713
C69.327,226.106,59.264,234.803,47.421,234.803z M116.993,234.803c-11.843,0-21.905-8.696-25.356-18.48h50.713
C138.898,226.106,128.835,234.803,116.993,234.803z M186.564,234.803c-11.843,0-21.905-8.696-25.357-18.48h50.713
C208.469,226.106,198.406,234.803,186.564,234.803z M256.135,234.803c-11.843,0-21.905-8.696-25.357-18.48h50.713
C278.04,226.106,267.977,234.803,256.135,234.803z M298.53,16.306h37.094l15.974,183.711H298.53V16.306z M325.706,234.803
c-11.843,0-21.905-8.696-25.356-18.48h50.713C347.611,226.106,337.548,234.803,325.706,234.803z M395.277,234.803
c-11.843,0-21.905-8.696-25.357-18.48h50.713C417.182,226.106,407.119,234.803,395.277,234.803z M464.848,234.803
c-11.843,0-21.905-8.696-25.356-18.48h50.713C486.753,226.106,476.691,234.803,464.848,234.803z M449.581,16.306l39.937,183.711
h-52.297L413.259,16.306H449.581z M396.815,16.306l23.962,183.711h-52.811L351.991,16.306H396.815z M282.224,200.017h-53.265V16.306
h53.265V200.017z M212.653,200.017h-51.982l15.974-183.711h36.007V200.017z M144.304,200.017H91.493l23.962-183.711h44.823
L144.304,200.017z M62.688,16.306h36.322L75.048,200.017H22.751L62.688,16.306z"/>
<path d="M412.67,260.892H273.528c-4.504,0-8.696,3.106-8.696,7.609v96.747h-8.696c-4.503,0-8.153,3.649-8.153,8.153
c0,4.504,3.65,8.153,8.153,8.153h173.928c4.504,0,8.153-3.649,8.153-8.153c0-4.504-3.649-8.153-8.153-8.153h-9.783v-96.747
C420.279,263.997,417.172,260.892,412.67,260.892z M281.137,365.248v-88.051h122.837v88.051H281.137z"/>
<path d="M177.867,360.357c-14.086,0-25.546,11.46-25.546,25.546c0,14.086,11.46,25.546,25.546,25.546
c14.086,0,25.546-11.46,25.546-25.546C203.413,371.816,191.953,360.357,177.867,360.357z M177.867,395.142
c-5.095,0-9.24-4.145-9.24-9.24c0-5.095,4.145-9.24,9.24-9.24c5.095,0,9.24,4.145,9.24,9.24
C187.107,390.997,182.962,395.142,177.867,395.142z"/>
<path d="M499.634,495.694h-27.176V320.675c0-4.504-3.649-8.153-8.153-8.153c-4.504,0-8.153,3.649-8.153,8.153v175.019H228.959
V268.501c0-4.504-3.107-7.609-7.609-7.609H82.207c-4.503,0-8.696,3.106-8.696,7.609v227.193h-18.48v-35.872
c0-4.504-3.65-8.153-8.153-8.153s-8.153,3.649-8.153,8.153v35.872H12.636c-4.503,0-8.153,3.649-8.153,8.153
c0,4.504,3.65,8.153,8.153,8.153h486.998c4.504,0,8.153-3.649,8.153-8.153C507.787,499.344,504.136,495.694,499.634,495.694z
M89.816,495.694V277.197h122.837v218.497H89.816z"/>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -14,6 +14,7 @@ const Header = () => {
const { fetchProducts, scrapeAll, loading } = useProductStore();
const [showAddModal, setShowAddModal] = useState(false);
const [backendVersion, setBackendVersion] = useState(null);
const [stats, setStats] = useState(null);
useEffect(() => {
api.fetchBackendVersion()
@@ -21,6 +22,19 @@ const Header = () => {
.catch(() => setBackendVersion("?"));
}, []);
// Fetch system stats toutes les 30 secondes
useEffect(() => {
const fetchStats = () => {
api.fetchSystemStats()
.then((data) => setStats(data))
.catch(() => setStats(null));
};
fetchStats();
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
const handleRefresh = () => {
fetchProducts();
};
@@ -38,7 +52,10 @@ const Header = () => {
<>
<header className="app-header">
<div className="brand">
<NavLink to="/">suivi_produits</NavLink>
<NavLink to="/">
<img className="brand-logo" src="/store.svg" alt="Store" />
suivi_produits
</NavLink>
</div>
<nav className="nav-links">
<NavLink to="/" end className={({ isActive }) => isActive ? "active" : ""}>
@@ -62,6 +79,19 @@ const Header = () => {
<i className={`fa-solid fa-refresh ${loading ? "fa-spin" : ""}`}></i>
</button>
</div>
{stats && (
<div className="system-stats">
<span title="CPU du process backend">
<i className="fa-solid fa-microchip"></i> {stats.cpu_percent}%
</span>
<span title="Mémoire du process backend">
<i className="fa-solid fa-memory"></i> {stats.memory_mb} Mo
</span>
<span title="Taille données (data + logs)">
<i className="fa-solid fa-database"></i> {stats.data_size_mb < 1000 ? `${stats.data_size_mb} Mo` : `${(stats.data_size_mb / 1024).toFixed(1)} Go`}
</span>
</div>
)}
<div className="version-info">
<span title="Version Frontend">FE v{FRONTEND_VERSION}</span>
<span title="Version Backend">BE v{backendVersion || "..."}</span>

View File

@@ -75,8 +75,8 @@ export const scrapePreview = async (url) => {
};
// Snapshots
export const fetchSnapshots = async (productId, limit = 30) => {
const response = await fetch(`${BASE_URL}/products/${productId}/snapshots?limit=${limit}`);
export const fetchSnapshots = async (productId, days = 30) => {
const response = await fetch(`${BASE_URL}/products/${productId}/snapshots?days=${days}`);
return handleResponse(response);
};
@@ -126,3 +126,31 @@ export const fetchBackendVersion = async () => {
const response = await fetch(`${BASE_URL}/version`);
return handleResponse(response);
};
// System stats
export const fetchSystemStats = async () => {
const response = await fetch(`${BASE_URL}/stats`);
return handleResponse(response);
};
// Database backup
export const fetchDatabaseInfo = async () => {
const response = await fetch(`${BASE_URL}/config/database/info`);
return handleResponse(response);
};
export const downloadDatabaseBackup = () => {
// Téléchargement direct via le navigateur
window.location.href = `${BASE_URL}/config/database/backup`;
};
export const restoreDatabase = async (file) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${BASE_URL}/config/database/restore`, {
method: "POST",
body: formData,
});
return handleResponse(response);
};

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
TimeScale,
LinearScale,
PointElement,
LineElement,
@@ -9,13 +9,15 @@ import {
Tooltip,
Filler,
} from "chart.js";
import "chartjs-adapter-date-fns";
import { fr } from "date-fns/locale";
import annotationPlugin from "chartjs-plugin-annotation";
import { Line } from "react-chartjs-2";
import * as api from "../../api/client";
// Enregistrer les composants Chart.js
ChartJS.register(
CategoryScale,
TimeScale,
LinearScale,
PointElement,
LineElement,
@@ -174,8 +176,15 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
}
}
const labels = sortedSnapshots.map((s) => formatDateLabel(s.scrape_le, spanDays));
const prices = sortedSnapshots.map((s) => s.prix_actuel);
// Données au format {x: Date, y: prix} pour TimeScale
const chartPoints = sortedSnapshots
.filter((s) => s.prix_actuel != null)
.map((s) => ({
x: parseUTCDate(s.scrape_le),
y: s.prix_actuel,
}));
const prices = chartPoints.map((p) => p.y);
// Calculer min/max
const validPrices = prices.filter((p) => p != null);
@@ -229,10 +238,9 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
}
const chartData = {
labels,
datasets: [
{
data: prices,
data: chartPoints,
borderColor: trend.color,
backgroundColor: `${trend.color}20`,
fill: true,
@@ -245,6 +253,14 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
],
};
// Déterminer l'unité de temps appropriée selon la période
const getTimeUnit = () => {
if (spanDays < 1) return "hour";
if (spanDays <= 7) return "day";
if (spanDays <= 90) return "week";
return "month";
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
@@ -260,7 +276,18 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
borderWidth: 1,
padding: 10,
callbacks: {
label: (context) => formatPrice(context.raw),
title: (context) => {
const date = context[0]?.raw?.x;
if (!date) return "";
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
},
label: (context) => formatPrice(context.raw?.y),
},
},
annotation: {
@@ -269,6 +296,21 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
},
scales: {
x: {
type: "time",
time: {
unit: getTimeUnit(),
displayFormats: {
hour: "HH:mm",
day: "dd/MM",
week: "dd/MM",
month: "MMM yyyy",
},
},
adapters: {
date: {
locale: fr,
},
},
grid: {
display: false,
},
@@ -276,7 +318,7 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
color: COLORS.muted,
font: { size: 10 },
maxRotation: 0,
maxTicksLimit: selectedPeriod <= 7 ? 7 : 5,
maxTicksLimit: 6,
},
},
y: {

View File

@@ -1,6 +1,7 @@
import React, { useState } from "react";
import ReactDOM from "react-dom";
import Lightbox from "../common/Lightbox";
import useProductStore from "../../stores/useProductStore";
const formatPrice = (price) => {
if (price == null) return null;
@@ -30,6 +31,41 @@ const formatDate = (dateStr) => {
const ProductDetailModal = ({ product, onClose }) => {
const [showLightbox, setShowLightbox] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editForm, setEditForm] = useState({
titre: product.titre || "",
categorie: product.categorie || "",
type: product.type || "",
actif: product.actif ?? true,
});
const { updateProduct } = useProductStore();
const handleEditChange = (field, value) => {
setEditForm((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setIsSaving(true);
try {
await updateProduct(product.id, editForm);
setIsEditing(false);
} catch (err) {
console.error("Erreur lors de la sauvegarde:", err);
} finally {
setIsSaving(false);
}
};
const handleCancelEdit = () => {
setEditForm({
titre: product.titre || "",
categorie: product.categorie || "",
type: product.type || "",
actif: product.actif ?? true,
});
setIsEditing(false);
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
@@ -57,9 +93,29 @@ const ProductDetailModal = ({ product, onClose }) => {
<h2>
<i className="fa-brands fa-amazon"></i> Détail du produit
</h2>
<button className="btn-close" onClick={onClose}>
<i className="fa-solid fa-times"></i>
</button>
<div className="modal-header-actions">
{!isEditing ? (
<button className="btn btn-edit" onClick={() => setIsEditing(true)}>
<i className="fa-solid fa-pen"></i> Éditer
</button>
) : (
<>
<button className="btn btn-save" onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<><i className="fa-solid fa-spinner fa-spin"></i> Enregistrement...</>
) : (
<><i className="fa-solid fa-check"></i> Enregistrer</>
)}
</button>
<button className="btn btn-cancel" onClick={handleCancelEdit} disabled={isSaving}>
<i className="fa-solid fa-times"></i> Annuler
</button>
</>
)}
<button className="btn-close" onClick={onClose}>
<i className="fa-solid fa-times"></i>
</button>
</div>
</div>
<div className="modal-body">
@@ -90,14 +146,37 @@ const ProductDetailModal = ({ product, onClose }) => {
<span className="boutique">
<i className="fa-brands fa-amazon"></i> {product.boutique}
</span>
{product.actif ? (
<span className="status active">Actif</span>
{isEditing ? (
<label className="status-toggle">
<input
type="checkbox"
checked={editForm.actif}
onChange={(e) => handleEditChange("actif", e.target.checked)}
/>
<span className={`status ${editForm.actif ? "active" : "inactive"}`}>
{editForm.actif ? "Actif" : "Inactif"}
</span>
</label>
) : (
<span className="status inactive">Inactif</span>
product.actif ? (
<span className="status active">Actif</span>
) : (
<span className="status inactive">Inactif</span>
)
)}
</div>
<h3 className="detail-title-large">{product.titre || "Titre non disponible"}</h3>
{isEditing ? (
<input
type="text"
className="edit-input edit-title"
value={editForm.titre}
onChange={(e) => handleEditChange("titre", e.target.value)}
placeholder="Titre du produit"
/>
) : (
<h3 className="detail-title-large">{product.titre || "Titre non disponible"}</h3>
)}
{/* Badges */}
{hasBadges && (
@@ -197,18 +276,40 @@ const ProductDetailModal = ({ product, onClose }) => {
<span className="label">ASIN</span>
<span className="value mono">{product.asin}</span>
</div>
{product.categorie && (
{product.categorie_amazon && (
<div className="detail-data-row">
<span className="label">Catégorie</span>
<span className="value">{product.categorie}</span>
</div>
)}
{product.type && (
<div className="detail-data-row">
<span className="label">Type</span>
<span className="value">{product.type}</span>
<span className="label">Catégorie Amazon</span>
<span className="value category-path">{product.categorie_amazon}</span>
</div>
)}
<div className="detail-data-row">
<span className="label">Catégorie</span>
{isEditing ? (
<input
type="text"
className="edit-input"
value={editForm.categorie}
onChange={(e) => handleEditChange("categorie", e.target.value)}
placeholder="Catégorie personnalisée"
/>
) : (
<span className="value">{product.categorie || "-"}</span>
)}
</div>
<div className="detail-data-row">
<span className="label">Type</span>
{isEditing ? (
<input
type="text"
className="edit-input"
value={editForm.type}
onChange={(e) => handleEditChange("type", e.target.value)}
placeholder="Type de produit"
/>
) : (
<span className="value">{product.type || "-"}</span>
)}
</div>
{product.cree_le && (
<div className="detail-data-row">
<span className="label">Ajouté le</span>

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useRef } from "react";
import useProductStore from "../stores/useProductStore";
import useConfigStore from "../stores/useConfigStore";
import ProductGrid from "../components/products/ProductGrid";
@@ -6,12 +6,39 @@ import ProductGrid from "../components/products/ProductGrid";
const HomePage = () => {
const { products, loading, error, fetchProducts, clearError } = useProductStore();
const { config, fetchConfig } = useConfigStore();
const refreshIntervalRef = useRef(null);
// Chargement initial
useEffect(() => {
fetchConfig();
fetchProducts();
}, [fetchProducts, fetchConfig]);
// Auto-refresh basé sur la config (rechargement complet de la page)
useEffect(() => {
const refreshInterval = config.ui?.refresh_auto_seconds || 0;
// Nettoyer l'ancien intervalle
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
refreshIntervalRef.current = null;
}
// Configurer le nouvel intervalle si > 0
if (refreshInterval > 0) {
refreshIntervalRef.current = setInterval(() => {
window.location.reload();
}, refreshInterval * 1000);
}
// Cleanup à la destruction du composant
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
}
};
}, [config.ui?.refresh_auto_seconds]);
const columns = config.ui?.columns_desktop || 3;
return (

View File

@@ -56,6 +56,11 @@ const useConfigStore = create((set) => ({
return state.config.ui?.image_ratio || 40;
},
getRefreshInterval: () => {
const state = useConfigStore.getState();
return state.config.ui?.refresh_auto_seconds || 0;
},
// Setter local pour mise à jour en temps réel (sans sauvegarder)
setImageRatioLocal: (ratio) => {
set((state) => ({

View File

@@ -54,6 +54,9 @@ a {
font-size: 1.5rem;
font-weight: 600;
a {
display: inline-flex;
align-items: center;
gap: 8px;
color: $text;
&:hover {
color: $accent;
@@ -61,6 +64,11 @@ a {
}
}
}
.brand-logo {
width: 22px;
height: 22px;
display: block;
}
.nav-links {
display: flex;
@@ -93,6 +101,28 @@ a {
gap: 8px;
}
.system-stats {
display: flex;
gap: 12px;
font-size: 0.75rem;
color: $gray;
font-family: monospace;
span {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: rgba($card, 0.5);
border-radius: 4px;
i {
color: $accent;
font-size: 0.7rem;
}
}
}
.version-info {
display: flex;
gap: 12px;