diff --git a/docker/schema_family.sql b/docker/schema_family.sql
index 7ef1d44..5a409a8 100644
--- a/docker/schema_family.sql
+++ b/docker/schema_family.sql
@@ -361,14 +361,25 @@ CREATE TABLE IF NOT EXISTS pf_todos (
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) 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 (
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
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_items_list (list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pf_grocery_history (
diff --git a/header.php b/header.php
index dce6083..09fd301 100644
--- a/header.php
+++ b/header.php
@@ -55,7 +55,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
= tr('menu_garage') ?>
= tr('menu_memo') ?>
= tr('menu_todo') ?>
- = tr('menu_groceries') ?>
+ = tr('menu_liste') ?>
= tr('menu_calendar_ios') ?>
= tr('menu_printvault') ?>
= tr('menu_planka') ?>
@@ -106,7 +106,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
🚗 = tr('menu_garage') ?>
📝 = tr('menu_memo') ?>
✅ = tr('menu_todo') ?>
- 🛒 = tr('menu_groceries') ?>
+ 🛒 = tr('menu_liste') ?>
📱 = tr('menu_calendar_ios') ?>
🖨️ = tr('menu_printvault') ?>
📋 = tr('menu_planka') ?>
diff --git a/includes/lang/fr.php b/includes/lang/fr.php
index cbb9212..1774d33 100644
--- a/includes/lang/fr.php
+++ b/includes/lang/fr.php
@@ -102,46 +102,43 @@ return [
'mod_calendar_ios_name' => 'Calendrier iOS',
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo',
- 'mod_groceries_name' => 'Courses',
- 'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
- 'menu_groceries' => 'Courses',
- 'groceries_placeholder_add' => 'Ex : lait, pain, tomates…',
- 'groceries_btn_add' => 'Ajouter',
- 'groceries_btn_uncheck_all' => 'Tout remettre à prendre',
- 'groceries_btn_delete_picked' => 'Retirer du caddie (supprimer)',
- 'groceries_subtitle' => '{pending} à prendre · {cart} dans le caddie',
- 'groceries_added' => 'Ajouté',
- 'groceries_updated' => 'Mis à jour',
- 'groceries_deleted' => 'Supprimé',
- 'groceries_reset_next' => 'Liste prête pour la prochaine sortie',
- 'groceries_picked_removed' => 'Articles retirés',
- 'groceries_label_empty' => 'Libellé vide',
- 'groceries_confirm_del_title' => 'Supprimer ?',
- 'groceries_confirm_del_body' => 'Retirer ce produit de la liste ?',
- 'groceries_confirm_uncheck_title' => 'Tout remettre à prendre',
- 'groceries_confirm_uncheck_body' => 'Les coches « dans le caddie » seront effacées. Les produits restent dans la liste.',
- 'groceries_confirm_clear_title' => 'Retirer les produits du caddie',
- 'groceries_confirm_clear_body' => 'Tous les articles cochés seront supprimés de la liste.',
- 'groceries_empty' => 'Rien pour le moment. Ajoutez un produit ci-dessus.',
- 'groceries_aria_toggle' => 'Marquer comme dans le caddie ou à prendre',
- 'groceries_section_pending' => 'À prendre',
- 'groceries_section_cart' => 'Dans le caddie',
- '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.',
+ ‘mod_groceries_name’ => ‘Courses’,
+ ‘mod_groceries_desc’ => ‘Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.’,
+ ‘menu_groceries’ => ‘Courses’,
+ // Module Liste (remplace Courses)
+ ‘mod_liste_name’ => ‘Listes’,
+ ‘mod_liste_desc’ => ‘Listes partagées nommées — courses, bricolage, pharmacie… Autant de listes que nécessaire.’,
+ ‘menu_liste’ => ‘Listes’,
+ ‘liste_placeholder_add’ => ‘Ajouter un article…’,
+ ‘liste_btn_add’ => ‘Ajouter’,
+ ‘liste_btn_uncheck_all’ => ‘Tout décocher’,
+ ‘liste_btn_delete_picked’ => ‘Supprimer les cochés’,
+ ‘liste_btn_clear_all’ => ‘Vider la liste’,
+ ‘liste_subtitle’ => ‘{pending} à faire · {cart} cochés’,
+ ‘liste_added’ => ‘Ajouté’,
+ ‘liste_updated’ => ‘Modifié’,
+ ‘liste_deleted’ => ‘Supprimé’,
+ ‘liste_already_in’ => ‘Déjà dans la liste’,
+ ‘liste_confirm_clear’ => ‘Vider toute la liste ?’,
+ ‘liste_confirm_delete_list’ => ‘Supprimer cette liste et tous ses articles ?’,
+ ‘liste_history_title’ => ‘Récemment utilisés — clic pour rajouter’,
+ ‘liste_new_list’ => ‘Nouvelle liste’,
+ ‘liste_list_name_placeholder’=> ‘Nom de la liste…’,
+ ‘liste_create’ => ‘Créer’,
+ ‘liste_rename_list’ => ‘Renommer’,
+ ‘liste_new_name’ => ‘Nouveau nom :’,
+ ‘liste_list_deleted’ => ‘Liste supprimée’,
+ ‘liste_list_created’ => ‘Liste créée’,
+ ‘liste_list_renamed’ => ‘Liste renommée’,
+ ‘liste_edit_placeholder’ => ‘Modifier l\’article :’,
+ ‘liste_empty_state’ => ‘Créez votre première liste avec le bouton + ci-dessus.’,
+ ‘liste_settings_title’ => ‘Listes — historique’,
+ ‘liste_settings_intro’ => ‘Nombre maximum d\’articles mémorisés dans l\’historique (commun à toutes les listes).’,
+ ‘liste_settings_history_max’ => ‘Taille de l\’historique’,
+ ‘liste_settings_history_hint’=> ‘Entre 1 et 50.’,
+ ‘groceries_settings_saved’ => ‘Préférences enregistrées.’,
+ ‘groceries_settings_error’ => ‘Impossible d\’enregistrer :’,
+ ‘groceries_err_duplicate’ => ‘Ce produit est déjà dans la liste.’,
'cta_open' => 'Ouvrir',
'cta_explore' => 'Explorer',
'cta_view_lists' => 'Déballer',
diff --git a/index.php b/index.php
index 84b8317..6051351 100644
--- a/index.php
+++ b/index.php
@@ -109,11 +109,11 @@ if ($_has_custom_bg): ?>
-
-
+
+
🛒
- = tr('mod_groceries_name') ?>
- = tr('mod_groceries_desc') ?>
+ = tr('mod_liste_name') ?>
+ = tr('mod_liste_desc') ?>
= tr('cta_cart') ?>
diff --git a/liste.php b/liste.php
new file mode 100644
index 0000000..aa7b271
--- /dev/null
+++ b/liste.php
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+ = tr('mod_liste_name') ?> — HouseHub
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📝
+
= tr('liste_empty_state') ?>
+
+
+
+
+
+
+
+
diff --git a/modules/liste/api.php b/modules/liste/api.php
new file mode 100644
index 0000000..054b918
--- /dev/null
+++ b/modules/liste/api.php
@@ -0,0 +1,234 @@
+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)
+try {
+ $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id");
+ $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)");
+} catch (Exception $e) { /* already exists */ }
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function normalize(string $s): string {
+ return mb_strtolower(trim(mb_substr($s, 0, 500)));
+}
+
+function touch_history(PDO $pdo, string $label): void {
+ $hash = hash('sha256', 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 ensure_default_list(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();
+}
+
+function valid_list_id(PDO $pdo, int $id): bool {
+ return (bool) $pdo->prepare("SELECT 1 FROM pf_lists WHERE id=?")
+ ->execute([$id]) &&
+ (bool) $pdo->prepare("SELECT 1 FROM pf_lists WHERE id=?")->execute([$id]) &&
+ (int) $pdo->prepare("SELECT COUNT(*) FROM pf_lists WHERE id=?")->execute([$id]) &&
+ (int) ($r = $pdo->prepare("SELECT COUNT(*) FROM pf_lists WHERE id=?")) &&
+ $r->execute([$id]) && (int)$r->fetchColumn() > 0;
+}
+
+function list_exists(PDO $pdo, int $id): bool {
+ $s = $pdo->prepare("SELECT COUNT(*) FROM pf_lists WHERE id=?");
+ $s->execute([$id]);
+ return (int)$s->fetchColumn() > 0;
+}
+
+// ── Body JSON ────────────────────────────────────────────────────────────────
+$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;
+}
+
+// ── Router ────────────────────────────────────────────────────────────────────
+
+// LISTS -----------------------------------------------------------------------
+if ($action === 'lists') {
+
+ if ($method === 'GET') {
+ $firstId = ensure_default_list($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);
+ $pos = (int) $pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
+ $s = $pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?, ?)");
+ $s->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; }
+ $name = mb_substr($name, 0, 100);
+ $pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $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; }
+ // Check duplicate
+ $norm = normalize($label);
+ $s = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?");
+ $s->execute([$list_id, $norm]);
+ if ((int)$s->fetchColumn() > 0) { echo json_encode(['duplicate' => true]); exit; }
+ $pos = (int) $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?")->execute([$list_id]) &&
+ ($q = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?")) &&
+ $q->execute([$list_id]) ? (int)$q->fetchColumn() : 0;
+ $ins = $pdo->prepare("INSERT INTO pf_grocery_items (list_id, label, in_cart, position) VALUES (?, ?, 0, ?)");
+ $ins->execute([$list_id, trim($label), $pos]);
+ $id = (int) $pdo->lastInsertId();
+ touch_history($pdo, $label);
+ echo json_encode(['id' => $id, 'list_id' => $list_id, 'label' => trim($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]);
+ 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; }
+ $row = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
+ $row->execute([$id]);
+ if ($r = $row->fetch()) touch_history($pdo, $r['label']);
+ $pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]);
+ echo json_encode(['ok' => true]);
+ exit;
+ }
+}
+
+// BULK ACTIONS ----------------------------------------------------------------
+if ($action === 'uncheck_all' && $method === 'POST') {
+ $list_id = (int) ($body['list_id'] ?? 0);
+ if (!$list_id) { http_response_code(400); echo json_encode(['error'=>'list_id required']); exit; }
+ $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) { http_response_code(400); echo json_encode(['error'=>'list_id required']); exit; }
+ $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) 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) { http_response_code(400); echo json_encode(['error'=>'list_id required']); exit; }
+ $rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?");
+ $rows->execute([$list_id]);
+ foreach ($rows->fetchAll() as $r) 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']);
diff --git a/modules/liste/assets/liste.css b/modules/liste/assets/liste.css
new file mode 100644
index 0000000..a82ccd3
--- /dev/null
+++ b/modules/liste/assets/liste.css
@@ -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; }
diff --git a/modules/liste/assets/liste.js b/modules/liste/assets/liste.js
new file mode 100644
index 0000000..6e513a4
--- /dev/null
+++ b/modules/liste/assets/liste.js
@@ -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,'&').replace(//g,'>').replace(/"/g,'"');
+}
+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 = `
+ ${esc(list.name)}
+
+ ${state.lists.length > 1 ? `` : ''}
+ `;
+ } else {
+ tab.innerHTML = `${esc(list.name)}`;
+ 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
+ ? `📝Liste vide — ajoutez un article ci-dessus.
`
+ : '';
+ 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 += `Dans le panier (${inCart.length})
` + 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 `
+
+
+
+
+
+
`;
+}
+
+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 = `
+
+
${T.history_title || 'Récemment utilisés'}
+
+ ${suggestions.map(h => ``).join('')}
+
+
`;
+ 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();
diff --git a/settings.php b/settings.php
index 9e2f38b..4d15c08 100644
--- a/settings.php
+++ b/settings.php
@@ -36,7 +36,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
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])));
if (empty($enabled)) {
$error = "Vous devez garder au moins un module actif.";
@@ -307,7 +307,7 @@ require __DIR__ . '/header.php';
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
'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')],
'printvault' => ['icon' => '🖨️', 'label' => tr('menu_printvault')],
'planka' => ['icon' => '📋', 'label' => tr('menu_planka')],
@@ -331,24 +331,24 @@ require __DIR__ . '/header.php';