@@ -355,6 +355,15 @@ CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
|||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
label_hash CHAR(64) NOT NULL,
|
||||||
|
label_display VARCHAR(500) NOT NULL,
|
||||||
|
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_grocery_hist_hash (label_hash),
|
||||||
|
KEY idx_hist_last (last_used_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- ─── Calendar iOS ─────────────────────────────────────────────────────────────
|
-- ─── Calendar iOS ─────────────────────────────────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ require __DIR__ . '/header.php';
|
|||||||
<div class="groceries-toolbar">
|
<div class="groceries-toolbar">
|
||||||
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn" onclick="uncheckAll()"><?= htmlspecialchars(tr('groceries_btn_uncheck_all')) ?></button>
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn" onclick="uncheckAll()"><?= htmlspecialchars(tr('groceries_btn_uncheck_all')) ?></button>
|
||||||
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--danger" onclick="deletePicked()"><?= htmlspecialchars(tr('groceries_btn_delete_picked')) ?></button>
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--danger" onclick="deletePicked()"><?= htmlspecialchars(tr('groceries_btn_delete_picked')) ?></button>
|
||||||
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--outline" onclick="clearWholeList()"><?= htmlspecialchars(tr('groceries_btn_clear_list')) ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="groceries-lists"></div>
|
<div id="groceries-lists"></div>
|
||||||
|
|
||||||
|
<div class="groceries-history-card" id="groceries-history-root" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/modules/groceries/assets/groceries.js"></script>
|
<script src="/modules/groceries/assets/groceries.js"></script>
|
||||||
|
|||||||
@@ -124,6 +124,21 @@ return [
|
|||||||
'groceries_section_pending' => 'Pendents',
|
'groceries_section_pending' => 'Pendents',
|
||||||
'groceries_section_cart' => 'Al carretó',
|
'groceries_section_cart' => 'Al carretó',
|
||||||
'groceries_prompt_edit' => 'Editar el producte',
|
'groceries_prompt_edit' => 'Editar el producte',
|
||||||
|
'groceries_btn_clear_list' => 'Buidar la llista',
|
||||||
|
'groceries_confirm_empty_list_title' => 'Buidar tota la llista?',
|
||||||
|
'groceries_confirm_empty_list_body' => 'Es trauran tots els productes. Romandran a l’historial per tornar-los a afegir ràpidament.',
|
||||||
|
'groceries_list_cleared' => 'Llista buidada',
|
||||||
|
'groceries_history_title' => 'Historial — un clic per afegir',
|
||||||
|
'groceries_history_empty' => 'L’historial es va omplint amb els productes que afegiu (fins al límit definit als paràmetres).',
|
||||||
|
'groceries_history_meta' => 'Fins a {max} productes · ',
|
||||||
|
'groceries_settings_link' => 'Paràmetres',
|
||||||
|
'groceries_settings_title' => 'Compra — historial',
|
||||||
|
'groceries_settings_intro' => 'Nombre màxim de productes recordats a l’historial (sense duplicats, sense distingir majúscules).',
|
||||||
|
'groceries_settings_history_max' => 'Mida de l’historial',
|
||||||
|
'groceries_settings_history_hint' => 'Entre 1 i 50. Les entrades més antigues se suprimeixen quan es supera el límit.',
|
||||||
|
'groceries_settings_saved' => 'Preferències de la compra desades.',
|
||||||
|
'groceries_settings_error' => 'No s’ha pogut desar:',
|
||||||
|
'groceries_err_duplicate' => 'Aquest producte ja és a la llista.',
|
||||||
'cta_open' => 'Obrir',
|
'cta_open' => 'Obrir',
|
||||||
'cta_explore' => 'Explorar',
|
'cta_explore' => 'Explorar',
|
||||||
'cta_view_lists' => 'Desembolicar',
|
'cta_view_lists' => 'Desembolicar',
|
||||||
|
|||||||
@@ -125,6 +125,21 @@ return [
|
|||||||
'groceries_section_pending' => 'To pick up',
|
'groceries_section_pending' => 'To pick up',
|
||||||
'groceries_section_cart' => 'In cart',
|
'groceries_section_cart' => 'In cart',
|
||||||
'groceries_prompt_edit' => 'Edit product',
|
'groceries_prompt_edit' => 'Edit product',
|
||||||
|
'groceries_btn_clear_list' => 'Clear list',
|
||||||
|
'groceries_confirm_empty_list_title' => 'Clear the entire list?',
|
||||||
|
'groceries_confirm_empty_list_body' => 'All products will be removed. They stay in history so you can add them again quickly.',
|
||||||
|
'groceries_list_cleared' => 'List cleared',
|
||||||
|
'groceries_history_title' => 'History — click to add',
|
||||||
|
'groceries_history_empty' => 'History fills as you add products (up to the limit set in Settings).',
|
||||||
|
'groceries_history_meta' => 'Up to {max} products · ',
|
||||||
|
'groceries_settings_link' => 'Settings',
|
||||||
|
'groceries_settings_title' => 'Groceries — history',
|
||||||
|
'groceries_settings_intro' => 'Maximum number of products remembered in history (no duplicates, case-insensitive).',
|
||||||
|
'groceries_settings_history_max' => 'History size',
|
||||||
|
'groceries_settings_history_hint' => 'Between 1 and 50. Oldest entries are removed when the limit is exceeded.',
|
||||||
|
'groceries_settings_saved' => 'Grocery preferences saved.',
|
||||||
|
'groceries_settings_error' => 'Could not save:',
|
||||||
|
'groceries_err_duplicate' => 'This product is already on the list.',
|
||||||
'cta_open' => 'Open',
|
'cta_open' => 'Open',
|
||||||
'cta_explore' => 'Explore',
|
'cta_explore' => 'Explore',
|
||||||
'cta_view_lists' => 'Unwrap',
|
'cta_view_lists' => 'Unwrap',
|
||||||
|
|||||||
@@ -127,8 +127,24 @@ return [
|
|||||||
'groceries_section_pending' => 'À prendre',
|
'groceries_section_pending' => 'À prendre',
|
||||||
'groceries_section_cart' => 'Dans le caddie',
|
'groceries_section_cart' => 'Dans le caddie',
|
||||||
'groceries_prompt_edit' => 'Modifier le produit',
|
'groceries_prompt_edit' => 'Modifier le produit',
|
||||||
|
'groceries_btn_clear_list' => 'Vider la liste',
|
||||||
|
'groceries_confirm_empty_list_title' => 'Vider toute la liste ?',
|
||||||
|
'groceries_confirm_empty_list_body' => 'Tous les produits seront retirés. Ils resteront disponibles dans l’historique pour les rajouter vite.',
|
||||||
|
'groceries_list_cleared' => 'Liste vidée',
|
||||||
|
'groceries_history_title' => 'Historique — un clic pour rajouter',
|
||||||
|
'groceries_history_empty' => 'L’historique se remplit au fur et à mesure des produits ajoutés (jusqu’à la limite définie dans les paramètres).',
|
||||||
|
'groceries_history_meta' => 'Jusqu’à {max} produits · ',
|
||||||
|
'groceries_settings_link' => 'Réglages dans Paramètres',
|
||||||
|
'groceries_settings_title' => 'Liste de courses — historique',
|
||||||
|
'groceries_settings_intro' => 'Nombre maximum de produits mémorisés dans l’historique (sans doublon, insensible à la casse).',
|
||||||
|
'groceries_settings_history_max' => 'Taille de l’historique',
|
||||||
|
'groceries_settings_history_hint' => 'Entre 1 et 50. Les entrées les plus anciennes sont supprimées quand la limite est dépassée.',
|
||||||
|
'groceries_settings_saved' => 'Préférences courses enregistrées.',
|
||||||
|
'groceries_settings_error' => 'Impossible d’enregistrer :',
|
||||||
|
'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.',
|
||||||
'cta_open' => 'Ouvrir',
|
'cta_open' => 'Ouvrir',
|
||||||
'cta_explore' => 'Explorer',
|
'cta_explore' => 'Explorer',
|
||||||
|
'cta_view_lists' => 'Déballer',
|
||||||
'cta_manage' => 'Gérer',
|
'cta_manage' => 'Gérer',
|
||||||
'cta_service' => 'Réviser',
|
'cta_service' => 'Réviser',
|
||||||
'cta_jot' => 'Griffonner',
|
'cta_jot' => 'Griffonner',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ set_exception_handler(function (\Throwable $e) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/i18n.php';
|
||||||
|
|
||||||
$pdo->exec("
|
$pdo->exec("
|
||||||
CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
||||||
@@ -30,6 +31,16 @@ CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
|||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
");
|
");
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
label_hash CHAR(64) NOT NULL,
|
||||||
|
label_display VARCHAR(500) NOT NULL,
|
||||||
|
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_grocery_hist_hash (label_hash),
|
||||||
|
KEY idx_hist_last (last_used_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
");
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$action = $_GET['action'] ?? '';
|
$action = $_GET['action'] ?? '';
|
||||||
@@ -50,6 +61,84 @@ function gBody()
|
|||||||
return json_decode(file_get_contents('php://input'), true) ?? [];
|
return json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groceryNormalize(string $label): string
|
||||||
|
{
|
||||||
|
$t = trim($label);
|
||||||
|
if ($t === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (function_exists('mb_strtolower')) {
|
||||||
|
$t = mb_strtolower($t, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
$t = strtolower($t);
|
||||||
|
}
|
||||||
|
if (function_exists('mb_strlen') && mb_strlen($t, 'UTF-8') > 500) {
|
||||||
|
$t = mb_substr($t, 0, 500, 'UTF-8');
|
||||||
|
} elseif (strlen($t) > 500) {
|
||||||
|
$t = substr($t, 0, 500);
|
||||||
|
}
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryHistoryMax(PDO $pdo): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$s = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'");
|
||||||
|
$v = $s ? $s->fetchColumn() : false;
|
||||||
|
$n = ($v !== false && $v !== null && $v !== '') ? (int) $v : 20;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$n = 20;
|
||||||
|
}
|
||||||
|
return max(1, min(50, $n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryTouchHistory(PDO $pdo, string $labelDisplay): void
|
||||||
|
{
|
||||||
|
$norm = groceryNormalize($labelDisplay);
|
||||||
|
if ($norm === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$disp = trim($labelDisplay);
|
||||||
|
if (function_exists('mb_strlen') && mb_strlen($disp, 'UTF-8') > 500) {
|
||||||
|
$disp = mb_substr($disp, 0, 500, 'UTF-8');
|
||||||
|
} elseif (strlen($disp) > 500) {
|
||||||
|
$disp = substr($disp, 0, 500);
|
||||||
|
}
|
||||||
|
$hash = hash('sha256', $norm, false);
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()'
|
||||||
|
)->execute([$hash, $disp]);
|
||||||
|
|
||||||
|
$max = groceryHistoryMax($pdo);
|
||||||
|
$cnt = (int) $pdo->query('SELECT COUNT(*) FROM pf_grocery_history')->fetchColumn();
|
||||||
|
if ($cnt <= $max) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$toDel = $cnt - $max;
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM pf_grocery_history ORDER BY last_used_at ASC, id ASC LIMIT ?');
|
||||||
|
$stmt->execute([$toDel]);
|
||||||
|
$ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!$ids) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$pdo->prepare("DELETE FROM pf_grocery_history WHERE id IN ($placeholders)")->execute($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryCurrentNormSet(PDO $pdo): array
|
||||||
|
{
|
||||||
|
$rows = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$set = [];
|
||||||
|
foreach ($rows as $lab) {
|
||||||
|
$n = groceryNormalize((string) $lab);
|
||||||
|
if ($n !== '') {
|
||||||
|
$set[$n] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'items') {
|
if ($action === 'items') {
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
$rows = $pdo->query(
|
$rows = $pdo->query(
|
||||||
@@ -72,9 +161,17 @@ if ($action === 'items') {
|
|||||||
if ($label === '') {
|
if ($label === '') {
|
||||||
gErr('Libellé requis');
|
gErr('Libellé requis');
|
||||||
}
|
}
|
||||||
|
$norm = groceryNormalize($label);
|
||||||
|
$chk = $pdo->query('SELECT label FROM pf_grocery_items');
|
||||||
|
while ($existing = $chk->fetchColumn()) {
|
||||||
|
if (groceryNormalize((string) $existing) === $norm) {
|
||||||
|
gErr(function_exists('tr') ? tr('groceries_err_duplicate') : 'Ce produit est déjà dans la liste.');
|
||||||
|
}
|
||||||
|
}
|
||||||
$max = (int) $pdo->query('SELECT COALESCE(MAX(position),0) FROM pf_grocery_items')->fetchColumn();
|
$max = (int) $pdo->query('SELECT COALESCE(MAX(position),0) FROM pf_grocery_items')->fetchColumn();
|
||||||
$pdo->prepare('INSERT INTO pf_grocery_items (label, position) VALUES (?,?)')->execute([$label, $max + 1]);
|
$pdo->prepare('INSERT INTO pf_grocery_items (label, position) VALUES (?,?)')->execute([$label, $max + 1]);
|
||||||
$id = (int) $pdo->lastInsertId();
|
$id = (int) $pdo->lastInsertId();
|
||||||
|
groceryTouchHistory($pdo, $label);
|
||||||
$s = $pdo->prepare('SELECT id, label, in_cart, position, created_at, updated_at FROM pf_grocery_items WHERE id=?');
|
$s = $pdo->prepare('SELECT id, label, in_cart, position, created_at, updated_at FROM pf_grocery_items WHERE id=?');
|
||||||
$s->execute([$id]);
|
$s->execute([$id]);
|
||||||
$row = $s->fetch(PDO::FETCH_ASSOC);
|
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||||
@@ -100,6 +197,7 @@ if ($action === 'items') {
|
|||||||
gErr('Libellé requis');
|
gErr('Libellé requis');
|
||||||
}
|
}
|
||||||
$pdo->prepare('UPDATE pf_grocery_items SET label=?, updated_at=NOW() WHERE id=?')->execute([$label, $id]);
|
$pdo->prepare('UPDATE pf_grocery_items SET label=?, updated_at=NOW() WHERE id=?')->execute([$label, $id]);
|
||||||
|
groceryTouchHistory($pdo, $label);
|
||||||
gOk(['updated' => true]);
|
gOk(['updated' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,14 +211,50 @@ if ($action === 'items') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'history' && $method === 'GET') {
|
||||||
|
$max = groceryHistoryMax($pdo);
|
||||||
|
$rows = $pdo->query(
|
||||||
|
'SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT 100'
|
||||||
|
)->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$onList = groceryCurrentNormSet($pdo);
|
||||||
|
$labels = [];
|
||||||
|
foreach ($rows as $disp) {
|
||||||
|
$disp = (string) $disp;
|
||||||
|
if (groceryNormalize($disp) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($onList[groceryNormalize($disp)])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$labels[] = $disp;
|
||||||
|
if (count($labels) >= $max) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gOk(['labels' => $labels, 'history_max' => $max]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'uncheck_all' && $method === 'POST') {
|
if ($action === 'uncheck_all' && $method === 'POST') {
|
||||||
$pdo->exec('UPDATE pf_grocery_items SET in_cart=0');
|
$pdo->exec('UPDATE pf_grocery_items SET in_cart=0');
|
||||||
gOk(['updated' => true]);
|
gOk(['updated' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'delete_picked' && $method === 'POST') {
|
if ($action === 'delete_picked' && $method === 'POST') {
|
||||||
|
$labs = $pdo->query('SELECT label FROM pf_grocery_items WHERE in_cart=1')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($labs as $lab) {
|
||||||
|
groceryTouchHistory($pdo, (string) $lab);
|
||||||
|
}
|
||||||
$pdo->exec('DELETE FROM pf_grocery_items WHERE in_cart=1');
|
$pdo->exec('DELETE FROM pf_grocery_items WHERE in_cart=1');
|
||||||
gOk(['deleted' => true]);
|
gOk(['deleted' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'clear_all' && $method === 'POST') {
|
||||||
|
$labs = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($labs as $lab) {
|
||||||
|
groceryTouchHistory($pdo, (string) $lab);
|
||||||
|
}
|
||||||
|
$pdo->exec('DELETE FROM pf_grocery_items');
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
gErr('Action inconnue', 404);
|
gErr('Action inconnue', 404);
|
||||||
|
|||||||
@@ -87,6 +87,16 @@
|
|||||||
border-color: rgba(239, 68, 68, 0.5) !important;
|
border-color: rgba(239, 68, 68, 0.5) !important;
|
||||||
color: var(--danger) !important;
|
color: var(--danger) !important;
|
||||||
}
|
}
|
||||||
|
.groceries-toolbar-btn--outline {
|
||||||
|
border: 1px dashed var(--border-strong) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--outline:hover {
|
||||||
|
background: var(--bg-page) !important;
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
border-color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.groceries-section-title {
|
.groceries-section-title {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -249,3 +259,90 @@
|
|||||||
[data-theme='dark'] .groceries-check.checked {
|
[data-theme='dark'] .groceries-check.checked {
|
||||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22), 0 2px 8px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22), 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Historique produits */
|
||||||
|
.groceries-history-card {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
padding: 1.15rem 1.2rem 1.2rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
.groceries-history-h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.groceries-history-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.groceries-history-settings-link {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.groceries-history-settings-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.groceries-history-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.groceries-history-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.75rem 0.45rem 0.55rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.groceries-history-chip:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
.groceries-history-chip-plus {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-on-primary, #fff);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.groceries-history-empty {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .groceries-history-card {
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-history-chip {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-history-chip:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,11 +54,12 @@ async function api(action, method = 'GET', data = null, extra = '') {
|
|||||||
return j.data;
|
return j.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadAll() {
|
||||||
try {
|
try {
|
||||||
const items = await api('items', 'GET');
|
const [items, hist] = await Promise.all([api('items', 'GET'), api('history', 'GET')]);
|
||||||
render(items);
|
render(items);
|
||||||
updateSubtitle(items);
|
updateSubtitle(items);
|
||||||
|
renderHistory(hist);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
}
|
}
|
||||||
@@ -79,6 +80,46 @@ function updateSubtitle(items) {
|
|||||||
.replace('{cart}', String(cart));
|
.replace('{cart}', String(cart));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderHistory(hist) {
|
||||||
|
const root = document.getElementById('groceries-history-root');
|
||||||
|
if (!root) return;
|
||||||
|
const max = hist && typeof hist.history_max === 'number' ? hist.history_max : 20;
|
||||||
|
const labels = (hist && hist.labels) || [];
|
||||||
|
const title = escHtml(T('groceries_history_title', 'Historique'));
|
||||||
|
const metaTpl = T('groceries_history_meta', 'Jusqu’à {max} produits · ');
|
||||||
|
const meta = escHtml(metaTpl.replace('{max}', String(max)));
|
||||||
|
const linkLabel = escHtml(T('groceries_settings_link', 'Paramètres'));
|
||||||
|
let chips = '';
|
||||||
|
labels.forEach((lab) => {
|
||||||
|
const l = String(lab);
|
||||||
|
chips +=
|
||||||
|
'<button type="button" class="groceries-history-chip" data-label="' +
|
||||||
|
escAttr(l) +
|
||||||
|
'"><span class="groceries-history-chip-plus">+</span>' +
|
||||||
|
escHtml(l) +
|
||||||
|
'</button>';
|
||||||
|
});
|
||||||
|
const emptyHint = labels.length
|
||||||
|
? ''
|
||||||
|
: '<p class="groceries-history-empty">' + escHtml(T('groceries_history_empty', '')) + '</p>';
|
||||||
|
root.innerHTML =
|
||||||
|
'<div class="groceries-history-inner">' +
|
||||||
|
'<div class="groceries-history-head">' +
|
||||||
|
'<h2 class="groceries-history-h2">' +
|
||||||
|
title +
|
||||||
|
'</h2>' +
|
||||||
|
'<p class="groceries-history-meta">' +
|
||||||
|
meta +
|
||||||
|
'<a href="/settings.php" class="groceries-history-settings-link">' +
|
||||||
|
linkLabel +
|
||||||
|
'</a></p></div>' +
|
||||||
|
'<div class="groceries-history-chips">' +
|
||||||
|
chips +
|
||||||
|
'</div>' +
|
||||||
|
emptyHint +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function render(items) {
|
function render(items) {
|
||||||
const wrap = document.getElementById('groceries-lists');
|
const wrap = document.getElementById('groceries-lists');
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
@@ -160,7 +201,19 @@ async function addFromInput() {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
input.focus();
|
input.focus();
|
||||||
toast(T('groceries_added', 'Ajouté'));
|
toast(T('groceries_added', 'Ajouté'));
|
||||||
loadItems();
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFromHistory(label) {
|
||||||
|
const t = String(label || '').trim();
|
||||||
|
if (!t) return;
|
||||||
|
try {
|
||||||
|
await api('items', 'POST', { label: t });
|
||||||
|
toast(T('groceries_added', 'Ajouté'));
|
||||||
|
loadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
}
|
}
|
||||||
@@ -169,7 +222,7 @@ async function addFromInput() {
|
|||||||
async function toggleCart(id, next) {
|
async function toggleCart(id, next) {
|
||||||
try {
|
try {
|
||||||
await api('items', 'PUT', { in_cart: !!parseInt(next, 10) }, '&id=' + id);
|
await api('items', 'PUT', { in_cart: !!parseInt(next, 10) }, '&id=' + id);
|
||||||
loadItems();
|
loadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
}
|
}
|
||||||
@@ -188,7 +241,7 @@ async function editItem(id) {
|
|||||||
try {
|
try {
|
||||||
await api('items', 'PUT', { label: t }, '&id=' + id);
|
await api('items', 'PUT', { label: t }, '&id=' + id);
|
||||||
toast(T('groceries_updated', 'Mis à jour'));
|
toast(T('groceries_updated', 'Mis à jour'));
|
||||||
loadItems();
|
loadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
}
|
}
|
||||||
@@ -203,7 +256,7 @@ async function deleteItem(id) {
|
|||||||
try {
|
try {
|
||||||
await api('items', 'DELETE', null, '&id=' + id);
|
await api('items', 'DELETE', null, '&id=' + id);
|
||||||
toast(T('groceries_deleted', 'Supprimé'));
|
toast(T('groceries_deleted', 'Supprimé'));
|
||||||
loadItems();
|
loadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
}
|
}
|
||||||
@@ -218,7 +271,7 @@ async function uncheckAll() {
|
|||||||
try {
|
try {
|
||||||
await api('uncheck_all', 'POST', {});
|
await api('uncheck_all', 'POST', {});
|
||||||
toast(T('groceries_reset_next', 'Liste prête pour la prochaine sortie'));
|
toast(T('groceries_reset_next', 'Liste prête pour la prochaine sortie'));
|
||||||
loadItems();
|
loadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
}
|
}
|
||||||
@@ -233,7 +286,22 @@ async function deletePicked() {
|
|||||||
try {
|
try {
|
||||||
await api('delete_picked', 'POST', {});
|
await api('delete_picked', 'POST', {});
|
||||||
toast(T('groceries_picked_removed', 'Articles retirés'));
|
toast(T('groceries_picked_removed', 'Articles retirés'));
|
||||||
loadItems();
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearWholeList() {
|
||||||
|
const ok = await pachaConfirm(
|
||||||
|
T('groceries_confirm_empty_list_title', 'Vider toute la liste ?'),
|
||||||
|
T('groceries_confirm_empty_list_body', '')
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api('clear_all', 'POST', {});
|
||||||
|
toast(T('groceries_list_cleared', 'Liste vidée'));
|
||||||
|
loadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
}
|
}
|
||||||
@@ -249,5 +317,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadItems();
|
const histRoot = document.getElementById('groceries-history-root');
|
||||||
|
if (histRoot) {
|
||||||
|
histRoot.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.groceries-history-chip');
|
||||||
|
if (!btn || !histRoot.contains(btn)) return;
|
||||||
|
const raw = btn.getAttribute('data-label');
|
||||||
|
if (raw) addFromHistory(raw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadAll();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -134,6 +134,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$success = "Image d'accueil réinitialisée.";
|
$success = "Image d'accueil réinitialisée.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'grocery_history_max' && $family_id) {
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
if (!isset($pdo)) {
|
||||||
|
$error = "Base famille indisponible.";
|
||||||
|
} else {
|
||||||
|
$n = (int) ($_POST['history_max'] ?? 20);
|
||||||
|
$n = max(1, min(50, $n));
|
||||||
|
try {
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('grocery_settings','history_max',?) ON DUPLICATE KEY UPDATE content=VALUES(content)"
|
||||||
|
)->execute([(string) $n]);
|
||||||
|
$success = tr('groceries_settings_saved');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = tr('groceries_settings_error') . ' ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'calendar_ios_save') {
|
if ($action === 'calendar_ios_save') {
|
||||||
$username = trim($_POST['icloud_username'] ?? '');
|
$username = trim($_POST['icloud_username'] ?? '');
|
||||||
$appPassword = hh_normalize_apple_app_password((string) ($_POST['icloud_app_password'] ?? ''));
|
$appPassword = hh_normalize_apple_app_password((string) ($_POST['icloud_app_password'] ?? ''));
|
||||||
@@ -216,6 +234,21 @@ $calendarIntegration = $meta_pdo->prepare("SELECT username, calendar_url, status
|
|||||||
$calendarIntegration->execute([$user_id]);
|
$calendarIntegration->execute([$user_id]);
|
||||||
$calendarIntegration = $calendarIntegration->fetch();
|
$calendarIntegration = $calendarIntegration->fetch();
|
||||||
|
|
||||||
|
$groceryHistoryMaxSetting = 20;
|
||||||
|
if ($family_id) {
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
if (isset($pdo)) {
|
||||||
|
try {
|
||||||
|
$gv = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'")->fetchColumn();
|
||||||
|
if ($gv !== false && $gv !== null && $gv !== '') {
|
||||||
|
$groceryHistoryMaxSetting = max(1, min(50, (int) $gv));
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$pageTitle = "Paramètres — HouseHub";
|
$pageTitle = "Paramètres — HouseHub";
|
||||||
$activePage = "settings";
|
$activePage = "settings";
|
||||||
require __DIR__ . '/header.php';
|
require __DIR__ . '/header.php';
|
||||||
@@ -293,6 +326,28 @@ require __DIR__ . '/header.php';
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$enabledModsForGrocery = $_SESSION['enabled_modules'] ?? [];
|
||||||
|
if ($family_id && in_array('groceries', $enabledModsForGrocery, true)):
|
||||||
|
?>
|
||||||
|
<section class="pf-panel-card">
|
||||||
|
<h2 class="pf-card-h2 pf-card-h2--tight">🛒 <?= htmlspecialchars(tr('groceries_settings_title')) ?></h2>
|
||||||
|
<p class="pf-muted-note"><?= htmlspecialchars(tr('groceries_settings_intro')) ?></p>
|
||||||
|
<form method="post" class="pf-stack-md" style="margin-top:1rem">
|
||||||
|
<input type="hidden" name="action" value="grocery_history_max">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label" for="history_max"><?= htmlspecialchars(tr('groceries_settings_history_max')) ?></label>
|
||||||
|
<input type="number" name="history_max" id="history_max" class="pf-input" style="max-width:120px" min="1" max="50" step="1" value="<?= (int) $groceryHistoryMaxSetting ?>" required>
|
||||||
|
<p class="pf-muted-note" style="margin-top:0.35rem"><?= htmlspecialchars(tr('groceries_settings_history_hint')) ?></p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="pf-btn"><?= htmlspecialchars(tr('btn_save')) ?></button>
|
||||||
|
</form>
|
||||||
|
<p class="pf-muted-note" style="margin-top:1rem">
|
||||||
|
<a href="/groceries.php" style="color:var(--primary);font-weight:600;"><?= htmlspecialchars(tr('mod_groceries_name')) ?></a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<section class="pf-panel-card">
|
<section class="pf-panel-card">
|
||||||
<h2 class="pf-card-h2 pf-card-h2--tight">📱 Intégration Calendrier iOS (CalDAV)</h2>
|
<h2 class="pf-card-h2 pf-card-h2--tight">📱 Intégration Calendrier iOS (CalDAV)</h2>
|
||||||
<p class="pf-muted-note">Configurez ici votre calendrier iCloud pour synchroniser les événements créés dans HouseHub.</p>
|
<p class="pf-muted-note">Configurez ici votre calendrier iCloud pour synchroniser les événements créés dans HouseHub.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user