Merge branch 'main' of http://nas.percolouco.com:3500/perco/HouseHub
Deploy HouseHub / deploy (push) Successful in 2s

This commit is contained in:
2026-05-19 19:16:47 +02:00
12 changed files with 1223 additions and 100 deletions
+13 -2
View File
@@ -372,14 +372,25 @@ CREATE TABLE IF NOT EXISTS pf_todos (
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Liste de courses ───────────────────────────────────────────────────────── -- ─── Module Liste (multi-listes) ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
CREATE TABLE IF NOT EXISTS pf_grocery_items ( CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT NOT NULL DEFAULT 1,
label VARCHAR(500) NOT NULL, label VARCHAR(500) NOT NULL,
in_cart TINYINT(1) NOT NULL DEFAULT 0, in_cart TINYINT(1) NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_items_list (list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_grocery_history ( CREATE TABLE IF NOT EXISTS pf_grocery_history (
+2 -2
View File
@@ -55,7 +55,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?> <?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?> <?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?> <?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('groceries', $mods)): ?><a href="/groceries.php" class="pf-nav-link <?= $activePage === 'groceries' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_groceries') ?></a><?php endif; ?> <?php if (in_array('liste', $mods)): ?><a href="/liste.php" class="pf-nav-link <?= $activePage === 'liste' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_liste') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?> <?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?> <?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-nav-link <?= $activePage === 'planka' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_planka') ?></a><?php endif; ?> <?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-nav-link <?= $activePage === 'planka' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_planka') ?></a><?php endif; ?>
@@ -106,7 +106,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?> <?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?> <?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?> <?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('groceries',$mods)): ?><a href="/groceries.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_groceries') ?></a><?php endif; ?> <?php if (in_array('liste',$mods)): ?><a href="/liste.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_liste') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?> <?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?> <?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-mobile-nav-link">📋 <?= tr('menu_planka') ?></a><?php endif; ?> <?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-mobile-nav-link">📋 <?= tr('menu_planka') ?></a><?php endif; ?>
+34 -37
View File
@@ -105,43 +105,40 @@ return [
'mod_groceries_name' => 'Courses', 'mod_groceries_name' => 'Courses',
'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.', 'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
'menu_groceries' => 'Courses', 'menu_groceries' => 'Courses',
'groceries_placeholder_add' => 'Ex : lait, pain, tomates…', // Module Liste (remplace Courses)
'groceries_btn_add' => 'Ajouter', 'mod_liste_name' => 'Listes',
'groceries_btn_uncheck_all' => 'Tout remettre à prendre', 'mod_liste_desc' => 'Listes partagées nommées — courses, bricolage, pharmacie… Autant de listes que nécessaire.',
'groceries_btn_delete_picked' => 'Retirer du caddie (supprimer)', 'menu_liste' => 'Listes',
'groceries_subtitle' => '{pending} à prendre · {cart} dans le caddie', 'liste_placeholder_add' => 'Ajouter un article…',
'groceries_added' => 'Ajouté', 'liste_btn_add' => 'Ajouter',
'groceries_updated' => 'Mis à jour', 'liste_btn_uncheck_all' => 'Tout décocher',
'groceries_deleted' => 'Supprimé', 'liste_btn_delete_picked' => 'Supprimer les cochés',
'groceries_reset_next' => 'Liste prête pour la prochaine sortie', 'liste_btn_clear_all' => 'Vider la liste',
'groceries_picked_removed' => 'Articles retirés', 'liste_subtitle' => '{pending} à faire · {cart} cochés',
'groceries_label_empty' => 'Libellé vide', 'liste_added' => 'Ajouté',
'groceries_confirm_del_title' => 'Supprimer ?', 'liste_updated' => 'Modifié',
'groceries_confirm_del_body' => 'Retirer ce produit de la liste ?', 'liste_deleted' => 'Supprimé',
'groceries_confirm_uncheck_title' => 'Tout remettre à prendre', 'liste_already_in' => 'Déjà dans la liste',
'groceries_confirm_uncheck_body' => 'Les coches « dans le caddie » seront effacées. Les produits restent dans la liste.', 'liste_confirm_clear' => 'Vider toute la liste ?',
'groceries_confirm_clear_title' => 'Retirer les produits du caddie', 'liste_confirm_delete_list' => 'Supprimer cette liste et tous ses articles ?',
'groceries_confirm_clear_body' => 'Tous les articles cochés seront supprimés de la liste.', 'liste_history_title' => 'Récemment utilisés — clic pour rajouter',
'groceries_empty' => 'Rien pour le moment. Ajoutez un produit ci-dessus.', 'liste_new_list' => 'Nouvelle liste',
'groceries_aria_toggle' => 'Marquer comme dans le caddie ou à prendre', 'liste_list_name_placeholder'=> 'Nom de la liste…',
'groceries_section_pending' => 'À prendre', 'liste_create' => 'Créer',
'groceries_section_cart' => 'Dans le caddie', 'liste_rename_list' => 'Renommer',
'groceries_prompt_edit' => 'Modifier le produit', 'liste_new_name' => 'Nouveau nom :',
'groceries_btn_clear_list' => 'Vider la liste', 'liste_list_deleted' => 'Liste supprimée',
'groceries_confirm_empty_list_title' => 'Vider toute la liste ?', 'liste_list_created' => 'Liste créée',
'groceries_confirm_empty_list_body' => 'Tous les produits seront retirés. Ils resteront disponibles dans lhistorique pour les rajouter vite.', 'liste_list_renamed' => 'Liste renommée',
'groceries_list_cleared' => 'Liste vidée', 'liste_edit_placeholder' => 'Modifier l\'article :',
'groceries_history_title' => 'Historique — un clic pour rajouter', 'liste_empty_state' => 'Créez votre première liste avec le bouton + ci-dessus.',
'groceries_history_empty' => 'Lhistorique se remplit au fur et à mesure des produits ajoutés (jusqu’à la limite définie dans les paramètres).', 'liste_settings_title' => 'Listes — historique',
'groceries_history_meta' => 'Jusqu’à {max} produits · ', 'liste_settings_intro' => 'Nombre maximum d\'articles mémorisés dans l\'historique (commun à toutes les listes).',
'groceries_settings_link' => 'Réglages dans Paramètres', 'liste_settings_history_max' => 'Taille de l\'historique',
'groceries_settings_title' => 'Liste de courses — historique', 'liste_settings_history_hint'=> 'Entre 1 et 50.',
'groceries_settings_intro' => 'Nombre maximum de produits mémorisés dans lhistorique (sans doublon, insensible à la casse).', 'groceries_settings_saved' => 'Préférences enregistrées.',
'groceries_settings_history_max' => 'Taille de lhistorique', 'groceries_settings_error' => 'Impossible d\'enregistrer :',
'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_err_duplicate' => 'Ce produit est déjà dans la liste.',
'groceries_settings_saved' => 'Préférences courses enregistrées.',
'groceries_settings_error' => 'Impossible denregistrer :',
'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_view_lists' => 'Déballer',
+4 -4
View File
@@ -109,11 +109,11 @@ if ($_has_custom_bg): ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if (in_array('groceries', $mods)): ?> <?php if (in_array('liste', $mods)): ?>
<a href="/groceries.php" class="pf-module-card"> <a href="/liste.php" class="pf-module-card">
<div class="pf-card-icon">🛒</div> <div class="pf-card-icon">🛒</div>
<h3 class="pf-card-title"><?= tr('mod_groceries_name') ?></h3> <h3 class="pf-card-title"><?= tr('mod_liste_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_groceries_desc') ?></div> <div class="pf-card-desc"><?= tr('mod_liste_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_cart') ?></span> <span class="pf-card-cta"><?= tr('cta_cart') ?></span>
</a> </a>
<?php endif; ?> <?php endif; ?>
+70
View File
@@ -0,0 +1,70 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('mod_liste_name') . ' — HouseHub';
$activePage = 'liste';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/liste/assets/liste.css">
<main class="liste-main">
<!-- Tabs des listes -->
<div class="liste-tabs-bar">
<div class="liste-tabs" id="liste-tabs"></div>
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div>
<!-- Formulaire de création de liste inline -->
<div class="liste-new-form hidden" id="liste-new-form">
<input type="text" id="new-list-name" maxlength="100"
placeholder="<?= htmlspecialchars(tr('liste_list_name_placeholder')) ?>" autocomplete="off">
<button id="btn-confirm-new-list"><?= htmlspecialchars(tr('liste_create')) ?></button>
<button id="btn-cancel-new-list"><?= htmlspecialchars(tr('cancel')) ?></button>
</div>
<!-- Corps de la liste active -->
<div class="liste-body" id="liste-body">
<div class="liste-add-card">
<input type="text" id="liste-input" maxlength="500"
placeholder="<?= htmlspecialchars(tr('liste_placeholder_add')) ?>" autocomplete="off">
<button id="btn-liste-add"><?= htmlspecialchars(tr('liste_btn_add')) ?></button>
</div>
<div class="liste-toolbar" id="liste-toolbar">
<button class="btn-tool" id="btn-uncheck-all"><?= htmlspecialchars(tr('liste_btn_uncheck_all')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-delete-picked"><?= htmlspecialchars(tr('liste_btn_delete_picked')) ?></button>
<button class="btn-tool btn-tool-danger" id="btn-clear-all"><?= htmlspecialchars(tr('liste_btn_clear_all')) ?></button>
</div>
<div id="liste-items-root"></div>
<div id="liste-history-root"></div>
</div>
</main>
<script>
const LISTE_TRANSLATIONS = {
already_in: <?= json_encode(tr('liste_already_in')) ?>,
confirm_clear: <?= json_encode(tr('liste_confirm_clear')) ?>,
confirm_delete_list: <?= json_encode(tr('liste_confirm_delete_list')) ?>,
rename_list: <?= json_encode(tr('liste_rename_list')) ?>,
new_name: <?= json_encode(tr('liste_new_name')) ?>,
list_deleted: <?= json_encode(tr('liste_list_deleted')) ?>,
list_created: <?= json_encode(tr('liste_list_created')) ?>,
list_renamed: <?= json_encode(tr('liste_list_renamed')) ?>,
edit_placeholder: <?= json_encode(tr('liste_edit_placeholder')) ?>,
history_title: <?= json_encode(tr('liste_history_title')) ?>,
added: <?= json_encode(tr('liste_added')) ?>,
updated: <?= json_encode(tr('liste_updated')) ?>,
deleted: <?= json_encode(tr('liste_deleted')) ?>,
cancel: <?= json_encode(tr('cancel')) ?>,
};
</script>
<script src="/modules/liste/assets/liste.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+218
View File
@@ -0,0 +1,218 @@
<?php
ob_start();
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
set_exception_handler(function (\Throwable $e) {
if (!headers_sent()) {
header('Content-Type: application/json');
http_response_code(500);
}
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
exit;
});
require_once dirname(__DIR__, 2) . '/includes/db.php';
// Auto-create tables
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY,
list_id INT NOT NULL DEFAULT 1,
label VARCHAR(500) NOT NULL,
in_cart TINYINT(1) NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_items_list (list_id)
) 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");
// Add list_id column if missing (migration from old groceries table)
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {}
$action = $_GET['action'] ?? $_POST['action'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
$body = [];
if ($method === 'PUT' || $method === 'POST') {
$raw = file_get_contents('php://input');
if ($raw) $body = json_decode($raw, true) ?? [];
foreach ($_POST as $k => $v) if (!isset($body[$k])) $body[$k] = $v;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function liste_normalize(string $s): string {
return mb_strtolower(trim(mb_substr($s, 0, 500)));
}
function liste_touch_history(PDO $pdo, string $label): void {
$hash = hash('sha256', liste_normalize($label));
$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, trim($label)]);
}
function liste_ensure_default(PDO $pdo): int {
$count = (int) $pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn();
if ($count === 0) {
$pdo->exec("INSERT INTO pf_lists (name, position) VALUES ('Ma liste', 0)");
return (int) $pdo->lastInsertId();
}
return (int) $pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
}
// ── LISTS ─────────────────────────────────────────────────────────────────────
if ($action === 'lists') {
if ($method === 'GET') {
$firstId = liste_ensure_default($pdo);
$rows = $pdo->query("SELECT id, name, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['lists' => $rows, 'default_id' => $firstId]);
exit;
}
if ($method === 'POST') {
$name = trim($body['name'] ?? '');
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
$name = mb_substr($name, 0, 100);
$s = $pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists");
$pos = (int) $s->fetchColumn();
$pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?, ?)")->execute([$name, $pos]);
$id = (int) $pdo->lastInsertId();
echo json_encode(['id' => $id, 'name' => $name, 'position' => $pos]);
exit;
}
if ($method === 'PUT') {
$id = (int) ($_GET['id'] ?? 0);
$name = trim($body['name'] ?? '');
if (!$id || $name === '') { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
$pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([mb_substr($name,0,100), $id]);
echo json_encode(['ok' => true]);
exit;
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
$count = (int) $pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn();
if ($count <= 1) { http_response_code(400); echo json_encode(['error' => 'cannot delete last list']); exit; }
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$id]);
$pdo->prepare("DELETE FROM pf_lists WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]);
exit;
}
}
// ── ITEMS ─────────────────────────────────────────────────────────────────────
if ($action === 'items') {
$list_id = (int) ($_GET['list_id'] ?? $body['list_id'] ?? 0);
if ($method === 'GET') {
if (!$list_id) { http_response_code(400); echo json_encode(['error' => 'list_id required']); exit; }
$s = $pdo->prepare("SELECT id, list_id, label, in_cart, position FROM pf_grocery_items WHERE list_id=? ORDER BY in_cart, position, id");
$s->execute([$list_id]);
echo json_encode(['items' => $s->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
if ($method === 'POST') {
$label = trim($body['label'] ?? '');
if (!$list_id || $label === '') { http_response_code(400); echo json_encode(['error' => 'missing fields']); exit; }
$norm = liste_normalize($label);
$dup = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?");
$dup->execute([$list_id, $norm]);
if ((int)$dup->fetchColumn() > 0) { echo json_encode(['duplicate' => true]); exit; }
$q = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?");
$q->execute([$list_id]);
$pos = (int) $q->fetchColumn();
$pdo->prepare("INSERT INTO pf_grocery_items (list_id, label, in_cart, position) VALUES (?,?,0,?)")->execute([$list_id, $label, $pos]);
$id = (int) $pdo->lastInsertId();
liste_touch_history($pdo, $label);
echo json_encode(['id' => $id, 'list_id' => $list_id, 'label' => $label, 'in_cart' => 0, 'position' => $pos]);
exit;
}
if ($method === 'PUT') {
$id = (int) ($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
if (isset($body['in_cart'])) {
$pdo->prepare("UPDATE pf_grocery_items SET in_cart=? WHERE id=?")->execute([(int)$body['in_cart'], $id]);
}
if (isset($body['label'])) {
$label = trim($body['label']);
if ($label !== '') { $pdo->prepare("UPDATE pf_grocery_items SET label=? WHERE id=?")->execute([$label, $id]); liste_touch_history($pdo, $label); }
}
echo json_encode(['ok' => true]);
exit;
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
$r = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
$r->execute([$id]);
if ($row = $r->fetch()) liste_touch_history($pdo, $row['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]);
exit;
}
}
// ── BULK ──────────────────────────────────────────────────────────────────────
if ($action === 'uncheck_all' && $method === 'POST') {
$list_id = (int)($body['list_id'] ?? 0);
if ($list_id) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=0 WHERE list_id=?")->execute([$list_id]);
echo json_encode(['ok' => true]); exit;
}
if ($action === 'delete_picked' && $method === 'POST') {
$list_id = (int)($body['list_id'] ?? 0);
if ($list_id) {
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=? AND in_cart=1");
$rows->execute([$list_id]);
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=? AND in_cart=1")->execute([$list_id]);
}
echo json_encode(['ok' => true]); exit;
}
if ($action === 'clear_all' && $method === 'POST') {
$list_id = (int)($body['list_id'] ?? 0);
if ($list_id) {
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?");
$rows->execute([$list_id]);
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$list_id]);
}
echo json_encode(['ok' => true]); exit;
}
// ── HISTORY ───────────────────────────────────────────────────────────────────
if ($action === 'history' && $method === 'GET') {
$max = 20;
$note = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='setting' AND reference_id='liste_history_max'");
$note->execute();
if ($n = $note->fetch()) $max = max(1, min(50, (int)$n['content']));
$s = $pdo->query("SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT $max");
echo json_encode(['history' => $s->fetchAll(PDO::FETCH_COLUMN)]); exit;
}
http_response_code(400);
echo json_encode(['error' => 'unknown action: ' . $action]);
+327
View File
@@ -0,0 +1,327 @@
/* ── Module Liste ─────────────────────────────────────────────────────────── */
.liste-main {
max-width: 680px;
margin: 0 auto;
padding: 1rem 1rem 4rem;
}
/* ── Tabs bar ──────────────────────────────────────────────────────────────── */
.liste-tabs-bar {
display: flex;
align-items: center;
gap: .4rem;
margin-bottom: .75rem;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.liste-tabs-bar::-webkit-scrollbar { display: none; }
.liste-tabs {
display: flex;
gap: .35rem;
flex-wrap: wrap;
}
.liste-tab {
display: flex;
align-items: center;
gap: .25rem;
padding: .4rem .75rem;
border-radius: 20px;
border: 1px solid var(--border, #dee2e6);
background: var(--card-bg, #fff);
cursor: pointer;
font-size: .875rem;
white-space: nowrap;
transition: background .15s, border-color .15s;
user-select: none;
}
.liste-tab:hover { background: var(--hover-bg, #f8f9fa); }
.liste-tab.active {
background: var(--primary, #4361ee);
color: #fff;
border-color: var(--primary, #4361ee);
font-weight: 600;
}
.liste-tab-name { pointer-events: none; }
.liste-tab-btn {
background: none;
border: none;
padding: 0 .1rem;
cursor: pointer;
font-size: .8rem;
color: rgba(255,255,255,.75);
line-height: 1;
transition: color .15s;
}
.liste-tab-btn:hover { color: #fff; }
.liste-tab-add {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px dashed var(--border, #dee2e6);
background: none;
cursor: pointer;
font-size: 1.1rem;
color: var(--muted, #6c757d);
display: flex;
align-items: center;
justify-content: center;
transition: border-color .15s, color .15s;
line-height: 1;
}
.liste-tab-add:hover {
border-color: var(--primary, #4361ee);
color: var(--primary, #4361ee);
}
/* ── New list form ─────────────────────────────────────────────────────────── */
.liste-new-form {
display: flex;
gap: .5rem;
align-items: center;
margin-bottom: .75rem;
flex-wrap: wrap;
}
.liste-new-form input {
flex: 1;
min-width: 160px;
padding: .45rem .75rem;
border: 1px solid var(--primary, #4361ee);
border-radius: 8px;
font-size: .9rem;
background: var(--input-bg, #fff);
color: var(--text, #212529);
}
.liste-new-form button {
padding: .45rem .9rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: .875rem;
}
.liste-new-form #btn-confirm-new-list {
background: var(--primary, #4361ee);
color: #fff;
}
.liste-new-form #btn-cancel-new-list {
background: var(--muted-bg, #e9ecef);
color: var(--text, #212529);
}
.hidden { display: none !important; }
/* ── Add card ──────────────────────────────────────────────────────────────── */
.liste-add-card {
display: flex;
gap: .5rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
padding: .6rem .75rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.05);
}
.liste-add-card input {
flex: 1;
border: none;
outline: none;
font-size: .95rem;
background: transparent;
color: var(--text, #212529);
}
.liste-add-card button {
background: var(--primary, #4361ee);
color: #fff;
border: none;
border-radius: 8px;
padding: .4rem .9rem;
cursor: pointer;
font-size: .875rem;
font-weight: 600;
white-space: nowrap;
}
/* ── Toolbar ───────────────────────────────────────────────────────────────── */
.liste-toolbar {
display: flex;
gap: .4rem;
flex-wrap: wrap;
margin-bottom: .75rem;
}
.btn-tool {
padding: .35rem .75rem;
border-radius: 8px;
border: 1px solid var(--border, #dee2e6);
background: var(--card-bg, #fff);
cursor: pointer;
font-size: .8rem;
color: var(--text, #212529);
transition: background .15s;
}
.btn-tool:hover { background: var(--hover-bg, #f8f9fa); }
.btn-tool-danger { color: var(--danger, #e63946); border-color: var(--danger, #e63946); }
.btn-tool-danger:hover { background: rgba(230,57,70,.08); }
/* ── Items ─────────────────────────────────────────────────────────────────── */
.liste-row {
display: flex;
align-items: center;
gap: .5rem;
padding: .55rem .75rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 10px;
margin-bottom: .4rem;
transition: opacity .2s;
}
.liste-row.in-cart { opacity: .55; }
.liste-row.in-cart .liste-label { text-decoration: line-through; }
.liste-check-label {
display: flex;
align-items: center;
gap: .6rem;
flex: 1;
cursor: pointer;
}
.liste-check {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--primary, #4361ee);
cursor: pointer;
flex-shrink: 0;
}
.liste-label { font-size: .95rem; }
.liste-row-actions {
display: flex;
gap: .25rem;
opacity: 0;
transition: opacity .15s;
}
.liste-row:hover .liste-row-actions,
.liste-row:focus-within .liste-row-actions { opacity: 1; }
.btn-edit-item, .btn-delete-item {
background: none;
border: none;
cursor: pointer;
padding: .2rem .3rem;
border-radius: 6px;
font-size: .9rem;
transition: background .15s;
}
.btn-edit-item:hover { background: var(--hover-bg, #f0f0f0); }
.btn-delete-item:hover { background: rgba(230,57,70,.1); }
.liste-cart-divider {
font-size: .78rem;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .04em;
padding: .5rem .25rem .25rem;
margin-top: .25rem;
}
.liste-empty-items {
text-align: center;
padding: 2rem 1rem;
color: var(--muted, #6c757d);
}
.liste-empty-items span { font-size: 2rem; display: block; margin-bottom: .5rem; }
/* ── History ───────────────────────────────────────────────────────────────── */
.liste-history-card {
margin-top: 1.25rem;
padding: .75rem;
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 12px;
}
.liste-history-title {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted, #6c757d);
margin-bottom: .5rem;
}
.liste-history-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
.liste-chip {
padding: .25rem .65rem;
border-radius: 20px;
border: 1px solid var(--border, #dee2e6);
background: var(--muted-bg, #f8f9fa);
cursor: pointer;
font-size: .82rem;
color: var(--text, #212529);
transition: background .15s, border-color .15s;
}
.liste-chip:hover {
background: var(--primary-light, #eef0fd);
border-color: var(--primary, #4361ee);
color: var(--primary, #4361ee);
}
/* ── Toast ─────────────────────────────────────────────────────────────────── */
.liste-toast-container {
position: fixed;
bottom: 1.5rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: .4rem;
z-index: 9999;
}
.liste-toast {
padding: .55rem 1rem;
border-radius: 8px;
font-size: .875rem;
background: #333;
color: #fff;
box-shadow: 0 3px 12px rgba(0,0,0,.2);
opacity: 0;
transform: translateY(8px);
transition: opacity .2s, transform .2s;
}
.liste-toast.show { opacity: 1; transform: translateY(0); }
.liste-toast-warn { background: #f0a500; }
.liste-toast-error { background: #e63946; }
/* ── Empty state (no lists at all) ────────────────────────────────────────── */
.liste-empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--muted, #6c757d);
}
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
/* ── Dark mode ─────────────────────────────────────────────────────────────── */
[data-theme='dark'] .liste-tab { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-tab:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-tab.active { background: #4361ee; color: #fff; border-color: #4361ee; }
[data-theme='dark'] .liste-add-card,
[data-theme='dark'] .liste-row,
[data-theme='dark'] .liste-history-card { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-add-card input { color: #cdd6f4; }
[data-theme='dark'] .liste-chip { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-chip:hover { background: #313155; border-color: #4361ee; color: #89b4fa; }
[data-theme='dark'] .btn-tool { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
+314
View File
@@ -0,0 +1,314 @@
/* invraw — Liste module JS */
const API = '/modules/liste/api.php';
const T = window.LISTE_TRANSLATIONS || {};
const state = {
lists: [],
currentListId: null,
items: [],
history: [],
};
// ── Utilities ─────────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function toast(msg, type = 'info') {
let c = document.getElementById('liste-toast-container');
if (!c) {
c = document.createElement('div');
c.id = 'liste-toast-container';
c.className = 'liste-toast-container';
document.body.appendChild(c);
}
const t = document.createElement('div');
t.className = `liste-toast liste-toast-${type}`;
t.textContent = msg;
c.appendChild(t);
setTimeout(() => t.classList.add('show'), 10);
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 2500);
}
async function api(action, method = 'GET', body = null, params = {}) {
const url = new URL(API, location.origin);
url.searchParams.set('action', action);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function renderTabs() {
const container = document.getElementById('liste-tabs');
if (!container) return;
container.innerHTML = '';
state.lists.forEach(list => {
const tab = document.createElement('div');
tab.className = 'liste-tab' + (list.id === state.currentListId ? ' active' : '');
tab.dataset.id = list.id;
if (list.id === state.currentListId) {
tab.innerHTML = `
<span class="liste-tab-name">${esc(list.name)}</span>
<button class="liste-tab-btn liste-tab-rename" title="${esc(T.rename_list)}" data-id="${list.id}">✏</button>
${state.lists.length > 1 ? `<button class="liste-tab-btn liste-tab-delete" title="Supprimer" data-id="${list.id}">×</button>` : ''}
`;
} else {
tab.innerHTML = `<span class="liste-tab-name">${esc(list.name)}</span>`;
tab.addEventListener('click', () => switchList(list.id));
}
container.appendChild(tab);
});
// Rename button
container.querySelectorAll('.liste-tab-rename').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); });
});
// Delete button
container.querySelectorAll('.liste-tab-delete').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
});
}
function startRename(listId) {
const list = state.lists.find(l => l.id === listId);
if (!list) return;
const name = prompt(T.new_name || 'Nouveau nom :', list.name);
if (name === null || name.trim() === '') return;
api('lists', 'PUT', { name: name.trim() }, { id: listId }).then(r => {
if (r.ok) {
list.name = name.trim();
renderTabs();
toast(T.list_renamed || 'Liste renommée');
}
});
}
async function deleteList(listId) {
if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return;
const r = await api('lists', 'DELETE', null, { id: listId });
if (r.error) { toast(r.error, 'error'); return; }
state.lists = state.lists.filter(l => l.id !== listId);
if (state.currentListId === listId) {
state.currentListId = state.lists[0]?.id ?? null;
}
await loadItems();
renderTabs();
toast(T.list_deleted || 'Liste supprimée');
}
// ── List management ────────────────────────────────────────────────────────────
document.getElementById('btn-add-list')?.addEventListener('click', () => {
document.getElementById('liste-new-form')?.classList.remove('hidden');
document.getElementById('btn-add-list')?.classList.add('hidden');
document.getElementById('new-list-name')?.focus();
});
document.getElementById('btn-cancel-new-list')?.addEventListener('click', cancelNewList);
document.getElementById('btn-confirm-new-list')?.addEventListener('click', confirmNewList);
document.getElementById('new-list-name')?.addEventListener('keydown', e => {
if (e.key === 'Enter') confirmNewList();
if (e.key === 'Escape') cancelNewList();
});
function cancelNewList() {
document.getElementById('liste-new-form')?.classList.add('hidden');
document.getElementById('btn-add-list')?.classList.remove('hidden');
if (document.getElementById('new-list-name')) document.getElementById('new-list-name').value = '';
}
async function confirmNewList() {
const input = document.getElementById('new-list-name');
const name = input?.value.trim();
if (!name) return;
const r = await api('lists', 'POST', { name });
if (r.id) {
state.lists.push({ id: r.id, name: r.name, position: r.position });
state.currentListId = r.id;
cancelNewList();
await loadItems();
renderTabs();
toast((T.list_created || 'Liste créée') + ' : ' + r.name);
}
}
// ── Switch list ────────────────────────────────────────────────────────────────
async function switchList(listId) {
state.currentListId = listId;
await loadItems();
renderTabs();
}
// ── Items ──────────────────────────────────────────────────────────────────────
async function loadItems() {
if (!state.currentListId) {
renderItems();
return;
}
const r = await api('items', 'GET', null, { list_id: state.currentListId });
state.items = r.items || [];
renderItems();
}
function renderItems() {
const root = document.getElementById('liste-items-root');
if (!root) return;
const toolbar = document.getElementById('liste-toolbar');
if (!state.currentListId || state.items.length === 0) {
root.innerHTML = state.currentListId
? `<div class="liste-empty-items"><span>📝</span><p>Liste vide — ajoutez un article ci-dessus.</p></div>`
: '';
if (toolbar) toolbar.style.display = 'none';
return;
}
if (toolbar) toolbar.style.display = '';
const pending = state.items.filter(i => !i.in_cart);
const inCart = state.items.filter(i => i.in_cart);
let html = '';
if (pending.length) html += pending.map(rowHtml).join('');
if (inCart.length) html += `<div class="liste-cart-divider">Dans le panier (${inCart.length})</div>` + inCart.map(rowHtml).join('');
root.innerHTML = html;
root.querySelectorAll('.liste-check').forEach(cb => {
cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0));
});
root.querySelectorAll('.btn-edit-item').forEach(btn => {
btn.addEventListener('click', () => editItem(parseInt(btn.dataset.id)));
});
root.querySelectorAll('.btn-delete-item').forEach(btn => {
btn.addEventListener('click', () => deleteItem(parseInt(btn.dataset.id)));
});
}
function rowHtml(item) {
const checked = item.in_cart ? 'checked' : '';
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
return `<div class="${cls}" data-id="${item.id}">
<label class="liste-check-label">
<input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}>
<span class="liste-label">${esc(item.label)}</span>
</label>
<div class="liste-row-actions">
<button class="btn-edit-item" data-id="${item.id}" title="Modifier">✏</button>
<button class="btn-delete-item" data-id="${item.id}" title="Supprimer">🗑</button>
</div>
</div>`;
}
async function addItem(label) {
const r = await api('items', 'POST', { list_id: state.currentListId, label });
if (r.duplicate) { toast(T.already_in || 'Déjà dans la liste', 'warn'); return; }
if (r.id) {
state.items.push(r);
renderItems();
renderHistory();
toast(T.added || 'Ajouté');
}
}
async function toggleCart(id, next) {
await api('items', 'PUT', { in_cart: next }, { id });
const item = state.items.find(i => i.id === id);
if (item) item.in_cart = next;
renderItems();
}
async function editItem(id) {
const item = state.items.find(i => i.id === id);
if (!item) return;
const newLabel = prompt(T.edit_placeholder || 'Modifier :', item.label);
if (newLabel === null || newLabel.trim() === '') return;
await api('items', 'PUT', { label: newLabel.trim() }, { id });
item.label = newLabel.trim();
renderItems();
toast(T.updated || 'Modifié');
}
async function deleteItem(id) {
await api('items', 'DELETE', null, { id });
state.items = state.items.filter(i => i.id !== id);
renderItems();
toast(T.deleted || 'Supprimé');
}
// ── Toolbar ────────────────────────────────────────────────────────────────────
document.getElementById('btn-uncheck-all')?.addEventListener('click', async () => {
if (!state.currentListId) return;
await api('uncheck_all', 'POST', { list_id: state.currentListId });
state.items.forEach(i => i.in_cart = 0);
renderItems();
});
document.getElementById('btn-delete-picked')?.addEventListener('click', async () => {
if (!state.currentListId) return;
await api('delete_picked', 'POST', { list_id: state.currentListId });
state.items = state.items.filter(i => !i.in_cart);
renderItems();
toast(T.deleted || 'Supprimés');
});
document.getElementById('btn-clear-all')?.addEventListener('click', async () => {
if (!confirm(T.confirm_clear || 'Vider toute la liste ?')) return;
await api('clear_all', 'POST', { list_id: state.currentListId });
state.items = [];
renderItems();
toast(T.deleted || 'Liste vidée');
});
// ── Add input ──────────────────────────────────────────────────────────────────
document.getElementById('btn-liste-add')?.addEventListener('click', () => {
const input = document.getElementById('liste-input');
const v = input?.value.trim();
if (v) { addItem(v); input.value = ''; input.focus(); }
});
document.getElementById('liste-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('btn-liste-add')?.click();
});
// ── History ────────────────────────────────────────────────────────────────────
async function loadHistory() {
const r = await api('history');
state.history = r.history || [];
renderHistory();
}
function renderHistory() {
const root = document.getElementById('liste-history-root');
if (!root || !state.history.length) { if (root) root.innerHTML = ''; return; }
const currentLabels = new Set(state.items.map(i => i.label.toLowerCase().trim()));
const suggestions = state.history.filter(h => !currentLabels.has(h.toLowerCase().trim()));
if (!suggestions.length) { root.innerHTML = ''; return; }
root.innerHTML = `
<div class="liste-history-card">
<div class="liste-history-title">${T.history_title || 'Récemment utilisés'}</div>
<div class="liste-history-chips">
${suggestions.map(h => `<button class="liste-chip" data-label="${esc(h)}">${esc(h)}</button>`).join('')}
</div>
</div>`;
root.querySelectorAll('.liste-chip').forEach(btn => {
btn.addEventListener('click', () => {
if (state.currentListId) addItem(btn.dataset.label);
});
});
}
// ── Init ───────────────────────────────────────────────────────────────────────
async function init() {
const r = await api('lists');
state.lists = r.lists || [];
state.currentListId = r.default_id || state.lists[0]?.id || null;
renderTabs();
await Promise.all([loadItems(), loadHistory()]);
}
init();
+46 -17
View File
@@ -68,13 +68,25 @@ if ($action === 'board') {
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant'); $id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
$resp = planka_api('GET', '/api/boards/' . $id, null, $token); $resp = planka_api('GET', '/api/boards/' . $id, null, $token);
$inc = $resp['included'] ?? []; $inc = $resp['included'] ?? [];
// Filter: only lists with a real name (Planka creates unnamed "None" placeholder lists)
$lists = array_values(array_filter($inc['lists'] ?? [], fn($l) =>
!empty($l['name']) && $l['name'] !== 'None' &&
(isset($l['boardId']) ? $l['boardId'] === $id : true)
));
// Filter cards that belong to a list in this board
$listIds = array_column($lists, 'id');
$cards = array_values(array_filter($inc['cards'] ?? [], fn($c) =>
in_array($c['listId'] ?? '', $listIds)
));
tOk([ tOk([
'lists' => $inc['lists'] ?? [], 'lists' => $lists,
'cards' => $inc['cards'] ?? [], 'cards' => $cards,
'labels' => $inc['labels'] ?? [], 'labels' => $inc['labels'] ?? [],
'members' => $inc['users'] ?? [], 'members' => $inc['users'] ?? [],
'cardLabels' => $inc['cardLabels'] ?? [], 'cardLabels' => $inc['cardLabels'] ?? [],
'tasks' => $inc['tasks'] ?? [], 'tasks' => $inc['tasks'] ?? [],
]); ]);
} }
@@ -84,28 +96,45 @@ if ($action === 'set_active_board') {
tOk(['updated' => true]); tOk(['updated' => true]);
} }
if ($action === 'create_card') { if ($action === 'create_list') {
$list_id = $_GET['list_id'] ?? null; if (!$list_id) tErr('list_id manquant');
$board_id = $_GET['board_id'] ?? null; if (!$board_id) tErr('board_id manquant'); $board_id = $_GET['board_id'] ?? null; if (!$board_id) tErr('board_id manquant');
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant'); $name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
$resp = planka_api('POST', '/api/boards/' . $board_id . '/cards', [ $position = (float) ($_GET['position'] ?? 65535);
'listId' => $list_id, $resp = planka_api('POST', '/api/boards/' . $board_id . '/lists', [
'name' => $name, 'name' => $name,
'position' => 65535, 'position' => $position,
'type' => 'active',
], $token); ], $token);
tOk($resp['item'] ?? []); if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Erreur Planka'), 502);
tOk($resp['item']);
}
if ($action === 'create_card') {
$list_id = $_GET['list_id'] ?? null; if (!$list_id) tErr('list_id manquant');
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
$pos = (float) ($_GET['position'] ?? 65535);
// Planka v2+: POST /api/lists/{listId}/cards with type required
$resp = planka_api('POST', '/api/lists/' . $list_id . '/cards', [
'name' => $name,
'position' => $pos,
'type' => 'story',
], $token);
if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Erreur Planka'), 502);
tOk($resp['item']);
} }
if ($action === 'update_card') { if ($action === 'update_card') {
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant'); $id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
$d = tBody(); $d = tBody();
$payload = []; $payload = [];
if (isset($d['name'])) $payload['name'] = $d['name']; if (isset($d['name'])) $payload['name'] = $d['name'];
if (isset($d['description'])) $payload['description'] = $d['description']; if (isset($d['description'])) $payload['description'] = $d['description'];
if (array_key_exists('dueDate', $d)) $payload['dueDate'] = $d['dueDate']; if (array_key_exists('dueDate', $d)) $payload['dueDate'] = $d['dueDate'];
if (isset($d['listId'])) $payload['listId'] = $d['listId']; if (isset($d['listId'])) $payload['listId'] = $d['listId'];
if (isset($d['position'])) $payload['position'] = (float)$d['position'];
$resp = planka_api('PATCH', '/api/cards/' . $id, $payload, $token); $resp = planka_api('PATCH', '/api/cards/' . $id, $payload, $token);
tOk($resp['item'] ?? []); if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Planka a rejeté la mise à jour'), 502);
tOk($resp['item']);
} }
if ($action === 'delete_card') { if ($action === 'delete_card') {
+76 -2
View File
@@ -166,10 +166,22 @@
} }
.pk-label { .pk-label {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 4px;
font-size: .72rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0,0,0,.3);
min-width: 36px;
}
.pk-label:empty {
height: 6px; height: 6px;
width: 36px; width: 36px;
border-radius: 3px; padding: 0;
display: inline-block;
} }
.pk-card-name { .pk-card-name {
@@ -444,3 +456,65 @@
.pk-page { height: auto; overflow: visible; } .pk-page { height: auto; overflow: visible; }
.pk-board-area { overflow-x: auto; height: auto; padding-bottom: 4rem; } .pk-board-area { overflow-x: auto; height: auto; padding-bottom: 4rem; }
} }
/* ── Drag & Drop amélioré ──────────────────────────────────────────────────── */
.pk-cards.drag-over {
background: rgba(59,130,246,.07);
outline: 2px dashed var(--primary, #4361ee);
outline-offset: -2px;
border-radius: 8px;
min-height: 80px;
}
.pk-card.is-dragging { opacity: .4; }
.pk-drop-hint {
text-align: center;
color: var(--text-muted, #9ca3af);
font-size: .78rem;
padding: .75rem;
border: 2px dashed var(--border-light, #e5e7eb);
border-radius: 8px;
margin-bottom: .35rem;
}
.pk-list-new {
background: transparent;
border: 2px dashed var(--border-light, #e5e7eb);
display: flex;
align-items: flex-start;
justify-content: center;
padding: .75rem .5rem;
}
.pk-add-list-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted, #9ca3af);
font-size: .9rem;
font-weight: 600;
padding: .4rem .75rem;
border-radius: 8px;
transition: background .15s, color .15s;
white-space: nowrap;
}
.pk-add-list-btn:hover {
background: var(--bg-panel, #f3f4f6);
color: var(--primary, #4361ee);
}
.pk-drop-before {
height: 4px;
border-radius: 4px;
transition: height .12s, background .12s;
margin: -2px 0;
}
.pk-drop-before.drop-before-active {
height: 28px;
background: rgba(59,130,246,.15);
border: 2px dashed var(--primary, #4361ee);
border-radius: 6px;
margin: 2px 0;
}
/* ── Indicateurs drop sur carte ────────────────────────────────────────────── */
.pk-card.drop-top { border-top: 3px solid var(--primary, #4361ee) !important; }
.pk-card.drop-bottom { border-bottom: 3px solid var(--primary, #4361ee) !important; }
+110 -27
View File
@@ -94,7 +94,6 @@ require __DIR__ . '/header.php';
const API = 'modules/planka/api.php'; const API = 'modules/planka/api.php';
let state = { boards: [], activeBoardId: null, lists: [], cards: [], labels: [], cardLabels: [], tasks: [] }; let state = { boards: [], activeBoardId: null, lists: [], cards: [], labels: [], cardLabels: [], tasks: [] };
let editingCard = null; let editingCard = null;
let dragCardId = null;
async function apiFetch(action, params = {}, method = 'GET', body = null) { async function apiFetch(action, params = {}, method = 'GET', body = null) {
const qs = new URLSearchParams({ action, ...params }).toString(); const qs = new URLSearchParams({ action, ...params }).toString();
@@ -146,18 +145,77 @@ async function loadBoard(id) {
function renderBoard() { function renderBoard() {
const area = document.getElementById('pk-board-area'); const area = document.getElementById('pk-board-area');
if (!state.lists.length) { area.innerHTML = '<div class="pk-loading">Aucune liste dans ce board.</div>'; return; }
const sorted = [...state.lists].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); const sorted = [...state.lists].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
area.innerHTML = sorted.map(list => renderList(list)).join(''); area.innerHTML = sorted.map(list => renderList(list)).join('') + `
<div class="pk-list pk-list-new">
<button class="pk-add-list-btn" onclick="promptCreateList()"> Ajouter une liste</button>
</div>`;
// ── Drag source ──────────────────────────────────────────────────────────
area.querySelectorAll('.pk-card').forEach(el => { area.querySelectorAll('.pk-card').forEach(el => {
el.addEventListener('click', () => openCardModal(el.dataset.id)); el.addEventListener('click', () => openCardModal(el.dataset.id));
el.addEventListener('dragstart', e => { dragCardId = el.dataset.id; el.classList.add('is-dragging'); }); el.addEventListener('dragstart', e => {
el.addEventListener('dragend', e => { el.classList.remove('is-dragging'); dragCardId = null; }); e.dataTransfer.setData('card-id', el.dataset.id);
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => el.classList.add('is-dragging'), 0);
});
el.addEventListener('dragend', () => {
el.classList.remove('is-dragging');
area.querySelectorAll('.drop-top,.drop-bottom,.drag-over').forEach(x =>
x.classList.remove('drop-top','drop-bottom','drag-over')
);
});
// ── Drop ON a card: top half = before, bottom half = after ──────────
el.addEventListener('dragover', e => {
e.preventDefault(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const mid = el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2;
if (e.clientY < mid) { el.classList.add('drop-top'); el.classList.remove('drop-bottom'); }
else { el.classList.add('drop-bottom'); el.classList.remove('drop-top'); }
});
el.addEventListener('dragleave', () => el.classList.remove('drop-top','drop-bottom'));
el.addEventListener('drop', e => {
e.preventDefault(); e.stopPropagation();
const isBefore = el.classList.contains('drop-top');
el.classList.remove('drop-top','drop-bottom');
const cardId = e.dataTransfer.getData('card-id');
if (!cardId || cardId === el.dataset.id) return;
const listId = el.dataset.listId;
const sorted = state.cards.filter(c => c.listId === listId && c.id !== cardId)
.sort((a, b) => (a.position||0) - (b.position||0));
const idx = sorted.findIndex(c => c.id === el.dataset.id);
let pos;
if (isBefore) {
pos = idx === 0
? (sorted[0].position || 65535) / 2
: ((sorted[idx-1].position||0) + (sorted[idx].position||0)) / 2;
} else {
pos = idx === sorted.length - 1
? (sorted[idx].position || 0) + 65535
: ((sorted[idx].position||0) + (sorted[idx+1].position||0)) / 2;
}
moveCard(cardId, listId, pos);
});
}); });
area.querySelectorAll('.pk-drop-zone').forEach(el => {
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); }); // ── Drop at end of column (empty list or after all cards) ───────────────
el.addEventListener('dragleave', () => el.classList.remove('drag-over')); area.querySelectorAll('.pk-cards').forEach(el => {
el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('drag-over'); dropCard(el.dataset.listId); }); el.addEventListener('dragover', e => {
// Only highlight column when not over a card
if (!e.target.closest('.pk-card')) { e.preventDefault(); el.classList.add('drag-over'); }
});
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
if (e.target.closest('.pk-card')) return; // handled by card listener
e.preventDefault();
el.classList.remove('drag-over');
const cardId = e.dataTransfer.getData('card-id');
if (!cardId) return;
const listId = el.dataset.listId;
const others = state.cards.filter(c => c.listId === listId && c.id !== cardId);
const maxPos = others.reduce((m, c) => Math.max(m, c.position||0), 0);
moveCard(cardId, listId, maxPos + 65535);
});
}); });
} }
@@ -169,8 +227,8 @@ function renderList(list) {
<span class="pk-list-name">${esc(list.name)}</span> <span class="pk-list-name">${esc(list.name)}</span>
<span class="pk-list-count">${cards.length}</span> <span class="pk-list-count">${cards.length}</span>
</div> </div>
<div class="pk-cards" id="cards-${list.id}"> <div class="pk-cards" id="cards-${list.id}" data-list-id="${list.id}">
<div class="pk-drop-zone" data-list-id="${list.id}"></div> ${cards.length === 0 ? '<div class="pk-drop-hint">Déposer ici</div>' : ''}
${cards.map(c => renderCard(c)).join('')} ${cards.map(c => renderCard(c)).join('')}
</div> </div>
<button class="pk-add-card-btn" onclick="quickAddCard('${list.id}')"> Ajouter une carte</button> <button class="pk-add-card-btn" onclick="quickAddCard('${list.id}')"> Ajouter une carte</button>
@@ -181,7 +239,7 @@ function renderCard(card) {
const labels = (state.cardLabels || []).filter(cl => cl.cardId === card.id).map(cl => { const labels = (state.cardLabels || []).filter(cl => cl.cardId === card.id).map(cl => {
const lbl = (state.labels || []).find(l => l.id === cl.labelId); const lbl = (state.labels || []).find(l => l.id === cl.labelId);
if (!lbl) return ''; if (!lbl) return '';
return `<span class="pk-label pk-label-color--${lbl.color || 'morning-sky'}" title="${esc(lbl.name || '')}"></span>`; return `<span class="pk-label pk-label-color--${lbl.color || 'morning-sky'}">${esc(lbl.name || '')}</span>`;
}).join(''); }).join('');
const cardTasks = (state.tasks || []).filter(t => t.cardId === card.id); const cardTasks = (state.tasks || []).filter(t => t.cardId === card.id);
const doneCount = cardTasks.filter(t => t.isCompleted).length; const doneCount = cardTasks.filter(t => t.isCompleted).length;
@@ -193,26 +251,33 @@ function renderCard(card) {
})() : ''; })() : '';
const taskHtml = cardTasks.length ? `<span class="pk-task-count">☑ ${doneCount}/${cardTasks.length}</span>` : ''; const taskHtml = cardTasks.length ? `<span class="pk-task-count">☑ ${doneCount}/${cardTasks.length}</span>` : '';
return ` return `
<div class="pk-card" data-id="${card.id}" draggable="true"> <div class="pk-card" data-id="${card.id}" data-list-id="${card.listId}" data-position="${card.position ?? 0}" draggable="true">
${labels ? `<div class="pk-card-labels">${labels}</div>` : ''} ${labels ? `<div class="pk-card-labels">${labels}</div>` : ''}
<div class="pk-card-name">${esc(card.name)}</div> <div class="pk-card-name">${esc(card.name)}</div>
${dueHtml || taskHtml ? `<div class="pk-card-meta">${dueHtml}${taskHtml}</div>` : ''} ${dueHtml || taskHtml ? `<div class="pk-card-meta">${dueHtml}${taskHtml}</div>` : ''}
</div>`; </div>`;
} }
async function dropCard(listId) { async function moveCard(cardId, listId, position) {
if (!dragCardId) return; const card = state.cards.find(c => c.id === cardId);
const card = state.cards.find(c => c.id === dragCardId); if (!card) return;
if (!card || card.listId === listId) return; // Skip no-op (same list, same position range)
await apiFetch('update_card', { id: dragCardId }, 'PATCH', { listId }); if (card.listId === listId && Math.abs((card.position || 0) - position) < 1) return;
card.listId = listId; const body = { listId, position };
const res = await apiFetch('update_card', { id: cardId }, 'PATCH', body);
if (!res.ok) { showToast('Erreur : ' + (res.error || 'déplacement impossible'), 'error'); return; }
card.listId = listId;
card.position = position;
renderBoard(); renderBoard();
showToast('Carte déplacée ✓');
} }
async function quickAddCard(listId) { async function quickAddCard(listId) {
const name = prompt('Nom de la carte :'); const name = prompt('Nom de la carte :');
if (!name || !name.trim()) return; if (!name || !name.trim()) return;
const res = await apiFetch('create_card', { list_id: listId, board_id: state.activeBoardId, name: name.trim() }); const listCards = state.cards.filter(c => c.listId === listId);
const maxPos = listCards.reduce((m, c) => Math.max(m, c.position || 0), 0);
const res = await apiFetch('create_card', { list_id: listId, name: name.trim(), position: maxPos + 65535 });
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; } if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
state.cards.push(res.data); state.cards.push(res.data);
renderBoard(); renderBoard();
@@ -250,22 +315,29 @@ function closeCardModal() {
async function saveCard() { async function saveCard() {
if (!editingCard) return; if (!editingCard) return;
const name = document.getElementById('pk-card-name').value.trim(); const name = document.getElementById('pk-card-name').value.trim();
if (!name) { showToast('Titre requis', 'error'); return; } if (!name) { showToast('Titre requis', 'error'); return; }
const due = document.getElementById('pk-card-due').value; const due = document.getElementById('pk-card-due').value;
const body = { const listId = document.getElementById('pk-card-list').value;
const body = {
name, name,
description: document.getElementById('pk-card-desc').value, description: document.getElementById('pk-card-desc').value,
dueDate: due ? due + 'T00:00:00.000Z' : null, dueDate: due ? due + 'T00:00:00.000Z' : null,
listId: document.getElementById('pk-card-list').value,
}; };
// Planka requires position whenever listId is sent — only include if list changed
if (listId !== editingCard.listId) {
body.listId = listId;
const targetCards = state.cards.filter(c => c.listId === listId);
const maxPos = targetCards.reduce((m, c) => Math.max(m, c.position || 0), 0);
body.position = maxPos + 65535;
}
const res = await apiFetch('update_card', { id: editingCard.id }, 'PATCH', body); const res = await apiFetch('update_card', { id: editingCard.id }, 'PATCH', body);
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; } if (!res.ok) { showToast('Erreur : ' + (res.error || 'sauvegarde impossible'), 'error'); return; }
const idx = state.cards.findIndex(c => c.id === editingCard.id); const idx = state.cards.findIndex(c => c.id === editingCard.id);
if (idx >= 0) state.cards[idx] = { ...state.cards[idx], ...res.data }; if (idx >= 0) state.cards[idx] = { ...state.cards[idx], ...res.data, listId: listId };
closeCardModal(); closeCardModal();
renderBoard(); renderBoard();
showToast('Carte enregistrée'); showToast('Carte enregistrée');
} }
async function deleteCard() { async function deleteCard() {
@@ -319,6 +391,17 @@ async function saveSettings() {
setTimeout(() => init(), 800); setTimeout(() => init(), 800);
} }
async function promptCreateList() {
const name = prompt('Nom de la nouvelle liste :');
if (!name || !name.trim()) return;
const maxPos = state.lists.reduce((m, l) => Math.max(m, l.position || 0), 0);
const res = await apiFetch('create_list', { board_id: state.activeBoardId, name: name.trim(), position: maxPos + 65535 });
if (!res.ok) { showToast('Erreur : ' + (res.error || 'création impossible'), 'error'); return; }
state.lists.push(res.data);
renderBoard();
showToast('Liste créée ✓');
}
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function formatDate(iso) { function formatDate(iso) {
+9 -9
View File
@@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if ($action === 'set_modules' && $family_id) { if ($action === 'set_modules' && $family_id) {
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'groceries', 'calendar_ios', 'printvault', 'planka']; $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'liste', 'calendar_ios', 'printvault', 'planka'];
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m]))); $enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
if (empty($enabled)) { if (empty($enabled)) {
$error = "Vous devez garder au moins un module actif."; $error = "Vous devez garder au moins un module actif.";
@@ -317,7 +317,7 @@ require __DIR__ . '/header.php';
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')], 'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')], 'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
'todo' => ['icon' => '✅', 'label' => tr('menu_todo')], 'todo' => ['icon' => '✅', 'label' => tr('menu_todo')],
'groceries' => ['icon' => '🛒', 'label' => tr('menu_groceries')], 'liste' => ['icon' => '📝', 'label' => tr('menu_liste')],
'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')], 'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')],
'printvault' => ['icon' => '🖨️', 'label' => tr('menu_printvault')], 'printvault' => ['icon' => '🖨️', 'label' => tr('menu_printvault')],
'planka' => ['icon' => '📋', 'label' => tr('menu_planka')], 'planka' => ['icon' => '📋', 'label' => tr('menu_planka')],
@@ -341,24 +341,24 @@ require __DIR__ . '/header.php';
<?php endif; ?> <?php endif; ?>
<?php <?php
$enabledModsForGrocery = $_SESSION['enabled_modules'] ?? []; $enabledModsForListe = $_SESSION['enabled_modules'] ?? [];
if ($family_id && in_array('groceries', $enabledModsForGrocery, true)): if ($family_id && in_array('liste', $enabledModsForListe, true)):
?> ?>
<section class="pf-panel-card"> <section class="pf-panel-card">
<h2 class="pf-card-h2 pf-card-h2--tight">🛒 <?= htmlspecialchars(tr('groceries_settings_title')) ?></h2> <h2 class="pf-card-h2 pf-card-h2--tight">📝 <?= htmlspecialchars(tr('liste_settings_title')) ?></h2>
<p class="pf-muted-note"><?= htmlspecialchars(tr('groceries_settings_intro')) ?></p> <p class="pf-muted-note"><?= htmlspecialchars(tr('liste_settings_intro')) ?></p>
<form method="post" class="pf-stack-md" style="margin-top:1rem"> <form method="post" class="pf-stack-md" style="margin-top:1rem">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
<input type="hidden" name="action" value="grocery_history_max"> <input type="hidden" name="action" value="grocery_history_max">
<div class="pf-form-group"> <div class="pf-form-group">
<label class="pf-label" for="history_max"><?= htmlspecialchars(tr('groceries_settings_history_max')) ?></label> <label class="pf-label" for="history_max"><?= htmlspecialchars(tr('liste_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> <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> <p class="pf-muted-note" style="margin-top:0.35rem"><?= htmlspecialchars(tr('liste_settings_history_hint')) ?></p>
</div> </div>
<button type="submit" class="pf-btn"><?= htmlspecialchars(tr('btn_save')) ?></button> <button type="submit" class="pf-btn"><?= htmlspecialchars(tr('btn_save')) ?></button>
</form> </form>
<p class="pf-muted-note" style="margin-top:1rem"> <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> <a href="/liste.php" style="color:var(--primary);font-weight:600;"><?= htmlspecialchars(tr('mod_liste_name')) ?></a>
</p> </p>
</section> </section>
<?php endif; ?> <?php endif; ?>