From 8176fc4de05042ca47c88b3f31de4245cf6aca2c Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 17:07:02 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20remplace=20module=20Courses=20par?= =?UTF-8?q?=20Liste=20multi-listes=20nomm=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouveau module 'liste' : plusieurs listes nommées par espace famille - Tabs de navigation entre les listes + création/renommage/suppression inline - Migration automatique : existing pf_grocery_items → list_id=1 (Ma liste) - Schema: nouvelle table pf_lists, ajout list_id sur pf_grocery_items - Historique global partagé entre toutes les listes - Ancien module 'groceries' conservé pour compatibilité ascendante - Mise à jour settings.php, header.php, index.php, schema_family.sql Co-Authored-By: Claude Sonnet 4.6 --- docker/schema_family.sql | 15 +- header.php | 4 +- includes/lang/fr.php | 77 ++++---- index.php | 8 +- liste.php | 94 ++++++++++ modules/liste/api.php | 234 +++++++++++++++++++++++ modules/liste/assets/liste.css | 327 +++++++++++++++++++++++++++++++++ modules/liste/assets/liste.js | 314 +++++++++++++++++++++++++++++++ settings.php | 18 +- 9 files changed, 1034 insertions(+), 57 deletions(-) create mode 100644 liste.php create mode 100644 modules/liste/api.php create mode 100644 modules/liste/assets/liste.css create mode 100644 modules/liste/assets/liste.js 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'; - + @@ -106,7 +106,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr'; 🚗 📝 - 🛒 + 🛒 📱 🖨️ 📋 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): ?> - - + +
🛒
-

-
+

+
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 + + + + + + +
+ + +
+
+ +
+ + + + + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + +
+ +
+ + + + +
+ + + + + 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';
-

🛒

-

+

📝

+

- + -

+

- +

From 987ce43e528efbd11d8a4fc149353bdf6df46c0b Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 17:10:55 +0200 Subject: [PATCH 2/9] fix: corrige parse error apostrophes fr.php et includes liste.php Co-Authored-By: Claude Sonnet 4.6 --- includes/lang/fr.php | 72 ++++++++++++++++++------------------ liste.php | 88 ++++++++++++++++---------------------------- 2 files changed, 68 insertions(+), 92 deletions(-) diff --git a/includes/lang/fr.php b/includes/lang/fr.php index 1774d33..f57802e 100644 --- a/includes/lang/fr.php +++ b/includes/lang/fr.php @@ -102,43 +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’, + '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.’, + '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/liste.php b/liste.php index aa7b271..1956885 100644 --- a/liste.php +++ b/liste.php @@ -1,94 +1,70 @@ - - - - - - <?= tr('mod_liste_name') ?> — HouseHub - - - - - + +
- +
-
- + placeholder="" autocomplete="off"> +
-
- - - + + +
-
- -
- - -
- - + From 3516225e5605885a7a4cb5f42c23b3530a4a5423 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 17:13:49 +0200 Subject: [PATCH 3/9] fix: corrige include paths et get_family_pdo() dans api.php Liste Co-Authored-By: Claude Sonnet 4.6 --- modules/liste/api.php | 198 +++++++++++++++++++----------------------- 1 file changed, 91 insertions(+), 107 deletions(-) diff --git a/modules/liste/api.php b/modules/liste/api.php index 054b918..b629895 100644 --- a/modules/liste/api.php +++ b/modules/liste/api.php @@ -1,26 +1,35 @@ 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', + 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, + 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) @@ -35,27 +44,35 @@ $pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_history ( 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 */ } +// 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) {} -// ── Helpers ────────────────────────────────────────────────────────────────── +$action = $_GET['action'] ?? $_POST['action'] ?? ''; +$method = $_SERVER['REQUEST_METHOD']; -function normalize(string $s): string { +$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 touch_history(PDO $pdo, string $label): void { - $hash = hash('sha256', normalize($label)); +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 ensure_default_list(PDO $pdo): int { +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)"); @@ -64,67 +81,38 @@ function ensure_default_list(PDO $pdo): int { 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; -} +// ── LISTS ───────────────────────────────────────────────────────────────────── -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); + $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; } + 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]); + $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; } - $name = mb_substr($name, 0, 100); - $pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $id]); + 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); + $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; } + 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]); @@ -132,95 +120,91 @@ if ($action === 'lists') { } } -// ITEMS ----------------------------------------------------------------------- +// ── 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; } + 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]); + 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(); - touch_history($pdo, $label); - echo json_encode(['id' => $id, 'list_id' => $list_id, 'label' => trim($label), 'in_cart' => 0, 'position' => $pos]); + 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 (!$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); - } + 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; } - $row = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?"); - $row->execute([$id]); - if ($r = $row->fetch()) touch_history($pdo, $r['label']); + 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 ACTIONS ---------------------------------------------------------------- +// ── BULK ────────────────────────────────────────────────────────────────────── + 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]); + $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) { 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]); + $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) { 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]); + $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 --------------------------------------------------------------------- +// ── 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'"); @@ -231,4 +215,4 @@ if ($action === 'history' && $method === 'GET') { } http_response_code(400); -echo json_encode(['error' => 'unknown action']); +echo json_encode(['error' => 'unknown action: ' . $action]); From 285273108ca3982d38e177a033107a0886d77128 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 17:21:31 +0200 Subject: [PATCH 4/9] fix(planka): drag & drop toute colonne + filtre listes par boardId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop target = toute la colonne .pk-cards (plus la zone invisible de 4px) - Filtre les listes et cartes strictement par boardId pour éliminer les listes fantômes - Indicateur visuel 'Déposer ici' sur colonnes vides - CSS: colonne highlight en bleu pointillé au survol drag Co-Authored-By: Claude Sonnet 4.6 --- modules/planka/api.php | 21 ++++++++++++++++----- modules/planka/assets/planka.css | 19 +++++++++++++++++++ planka.php | 11 ++++++----- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/modules/planka/api.php b/modules/planka/api.php index 17e78f6..b83b993 100644 --- a/modules/planka/api.php +++ b/modules/planka/api.php @@ -68,13 +68,24 @@ if ($action === 'board') { $id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant'); $resp = planka_api('GET', '/api/boards/' . $id, null, $token); $inc = $resp['included'] ?? []; + + // Filter lists that actually belong to this board (prevents ghost lists from other boards) + $lists = array_values(array_filter($inc['lists'] ?? [], fn($l) => + 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([ - 'lists' => $inc['lists'] ?? [], - 'cards' => $inc['cards'] ?? [], - 'labels' => $inc['labels'] ?? [], - 'members' => $inc['users'] ?? [], + 'lists' => $lists, + 'cards' => $cards, + 'labels' => $inc['labels'] ?? [], + 'members' => $inc['users'] ?? [], 'cardLabels' => $inc['cardLabels'] ?? [], - 'tasks' => $inc['tasks'] ?? [], + 'tasks' => $inc['tasks'] ?? [], ]); } diff --git a/modules/planka/assets/planka.css b/modules/planka/assets/planka.css index bf87f90..0e418f4 100644 --- a/modules/planka/assets/planka.css +++ b/modules/planka/assets/planka.css @@ -444,3 +444,22 @@ .pk-page { height: auto; overflow: visible; } .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; +} diff --git a/planka.php b/planka.php index 4a01f1a..55fb509 100644 --- a/planka.php +++ b/planka.php @@ -151,12 +151,13 @@ function renderBoard() { area.innerHTML = sorted.map(list => renderList(list)).join(''); area.querySelectorAll('.pk-card').forEach(el => { el.addEventListener('click', () => openCardModal(el.dataset.id)); - el.addEventListener('dragstart', e => { dragCardId = el.dataset.id; el.classList.add('is-dragging'); }); + el.addEventListener('dragstart', e => { dragCardId = el.dataset.id; el.classList.add('is-dragging'); e.stopPropagation(); }); el.addEventListener('dragend', e => { el.classList.remove('is-dragging'); dragCardId = null; }); }); - area.querySelectorAll('.pk-drop-zone').forEach(el => { + // Drop target = toute la colonne .pk-cards (pas juste une zone invisible de 4px) + area.querySelectorAll('.pk-cards').forEach(el => { el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); }); - el.addEventListener('dragleave', () => el.classList.remove('drag-over')); + el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); }); el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('drag-over'); dropCard(el.dataset.listId); }); }); } @@ -169,8 +170,8 @@ function renderList(list) { ${esc(list.name)} ${cards.length} -
-
+
+ ${cards.length === 0 ? '
Déposer ici
' : ''} ${cards.map(c => renderCard(c)).join('')}
From 7d62aa02be71b7c003a2a2d8bc133748290860c3 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 17:30:12 +0200 Subject: [PATCH 5/9] =?UTF-8?q?fix(planka):=20position=20obligatoire=20pou?= =?UTF-8?q?r=20d=C3=A9placer=20une=20carte=20+=20filtre=20listes=20None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dropCard envoie position (max+65535) car Planka l'exige pour changer listId - Ajout vérification res.ok + toast d'erreur/succès - Filtre les listes avec name='None' (placeholders Planka sans nom) Co-Authored-By: Claude Sonnet 4.6 --- modules/planka/api.php | 5 +++-- planka.php | 9 ++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/modules/planka/api.php b/modules/planka/api.php index b83b993..b5d58a1 100644 --- a/modules/planka/api.php +++ b/modules/planka/api.php @@ -69,9 +69,10 @@ if ($action === 'board') { $resp = planka_api('GET', '/api/boards/' . $id, null, $token); $inc = $resp['included'] ?? []; - // Filter lists that actually belong to this board (prevents ghost lists from other boards) + // Filter: only lists with a real name (Planka creates unnamed "None" placeholder lists) $lists = array_values(array_filter($inc['lists'] ?? [], fn($l) => - isset($l['boardId']) ? $l['boardId'] === $id : true + !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'); diff --git a/planka.php b/planka.php index 55fb509..6b9e6d0 100644 --- a/planka.php +++ b/planka.php @@ -205,9 +205,16 @@ async function dropCard(listId) { if (!dragCardId) return; const card = state.cards.find(c => c.id === dragCardId); if (!card || card.listId === listId) return; - await apiFetch('update_card', { id: dragCardId }, 'PATCH', { listId }); + // Planka requires position when changing listId + const targetCards = state.cards.filter(c => c.listId === listId); + const maxPos = targetCards.reduce((m, c) => Math.max(m, c.position || 0), 0); + const position = maxPos + 65535; + const res = await apiFetch('update_card', { id: dragCardId }, 'PATCH', { listId, position }); + if (!res.ok) { showToast('Erreur déplacement : ' + (res.error || 'inconnu'), 'error'); return; } card.listId = listId; + card.position = position; renderBoard(); + showToast('Carte déplacée'); } async function quickAddCard(listId) { From 0db14560a56149d864ab454397279eecfb993371 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 17:43:16 +0200 Subject: [PATCH 6/9] fix(planka): drag&drop via dataTransfer + create_list + synchro position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drag & drop: utilise e.dataTransfer (pas de variable globale fragile) - dropCard prend cardId en paramètre, envoie position requis par Planka - Nouveau bouton 'Ajouter une liste' en fin de board - Endpoint create_list dans api.php (POST /api/boards/{id}/lists) Co-Authored-By: Claude Sonnet 4.6 --- modules/planka/api.php | 12 ++++++++ modules/planka/assets/planka.css | 25 +++++++++++++++++ planka.php | 48 ++++++++++++++++++++++---------- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/modules/planka/api.php b/modules/planka/api.php index b5d58a1..06dce12 100644 --- a/modules/planka/api.php +++ b/modules/planka/api.php @@ -96,6 +96,18 @@ if ($action === 'set_active_board') { tOk(['updated' => true]); } +if ($action === 'create_list') { + $board_id = $_GET['board_id'] ?? null; if (!$board_id) tErr('board_id manquant'); + $name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant'); + $position = (int) ($_GET['position'] ?? 65535); + $resp = planka_api('POST', '/api/boards/' . $board_id . '/lists', [ + 'name' => $name, + 'position' => $position, + ], $token); + if (empty($resp['item'])) tErr($resp['message'] ?? 'Planka n\'a pas créé la liste', 502); + tOk($resp['item']); +} + if ($action === 'create_card') { $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'); diff --git a/modules/planka/assets/planka.css b/modules/planka/assets/planka.css index 0e418f4..eb8ab33 100644 --- a/modules/planka/assets/planka.css +++ b/modules/planka/assets/planka.css @@ -463,3 +463,28 @@ 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); +} diff --git a/planka.php b/planka.php index 6b9e6d0..4af8c94 100644 --- a/planka.php +++ b/planka.php @@ -94,7 +94,6 @@ require __DIR__ . '/header.php'; const API = 'modules/planka/api.php'; let state = { boards: [], activeBoardId: null, lists: [], cards: [], labels: [], cardLabels: [], tasks: [] }; let editingCard = null; -let dragCardId = null; async function apiFetch(action, params = {}, method = 'GET', body = null) { const qs = new URLSearchParams({ action, ...params }).toString(); @@ -146,19 +145,30 @@ async function loadBoard(id) { function renderBoard() { const area = document.getElementById('pk-board-area'); - if (!state.lists.length) { area.innerHTML = '
Aucune liste dans ce board.
'; return; } 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('') + ` +
+ +
`; area.querySelectorAll('.pk-card').forEach(el => { el.addEventListener('click', () => openCardModal(el.dataset.id)); - el.addEventListener('dragstart', e => { dragCardId = el.dataset.id; el.classList.add('is-dragging'); e.stopPropagation(); }); - el.addEventListener('dragend', e => { el.classList.remove('is-dragging'); dragCardId = null; }); + el.addEventListener('dragstart', e => { + e.dataTransfer.setData('text/plain', el.dataset.id); + e.dataTransfer.effectAllowed = 'move'; + el.classList.add('is-dragging'); + }); + el.addEventListener('dragend', e => { el.classList.remove('is-dragging'); }); }); - // Drop target = toute la colonne .pk-cards (pas juste une zone invisible de 4px) + // Drop target = toute la colonne .pk-cards area.querySelectorAll('.pk-cards').forEach(el => { - el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); }); + el.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; el.classList.add('drag-over'); }); el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); }); - el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('drag-over'); dropCard(el.dataset.listId); }); + el.addEventListener('drop', e => { + e.preventDefault(); + el.classList.remove('drag-over'); + const cardId = e.dataTransfer.getData('text/plain'); + if (cardId) dropCard(el.dataset.listId, cardId); + }); }); } @@ -201,20 +211,19 @@ function renderCard(card) {
`; } -async function dropCard(listId) { - if (!dragCardId) return; - const card = state.cards.find(c => c.id === dragCardId); +async function dropCard(listId, cardId) { + const card = state.cards.find(c => c.id === cardId); if (!card || card.listId === listId) return; // Planka requires position when changing listId const targetCards = state.cards.filter(c => c.listId === listId); const maxPos = targetCards.reduce((m, c) => Math.max(m, c.position || 0), 0); const position = maxPos + 65535; - const res = await apiFetch('update_card', { id: dragCardId }, 'PATCH', { listId, position }); - if (!res.ok) { showToast('Erreur déplacement : ' + (res.error || 'inconnu'), 'error'); return; } + const res = await apiFetch('update_card', { id: cardId }, 'PATCH', { listId, position }); + if (!res.ok) { showToast('Erreur : ' + (res.error || 'déplacement impossible'), 'error'); return; } card.listId = listId; card.position = position; renderBoard(); - showToast('Carte déplacée'); + showToast('Carte déplacée ✓'); } async function quickAddCard(listId) { @@ -327,6 +336,17 @@ async function saveSettings() { 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 formatDate(iso) { From 5828d1796db87dcc4b52a022d26d511d1fe4aa39 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 17:49:26 +0200 Subject: [PATCH 7/9] fix(planka): endpoints v2, tri intra-liste, drag&drop robuste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_card: POST /api/lists/{id}/cards + type=story (Planka v2 requis) - create_list: type=active requis - Drag & drop: zones pk-drop-before avant chaque carte pour tri intra-liste - moveCard(): position calculée (avg prev/next) + gestion même liste - Suppression variable dragCardId globale → dataTransfer propre Co-Authored-By: Claude Sonnet 4.6 --- modules/planka/api.php | 21 +++++----- modules/planka/assets/planka.css | 14 +++++++ planka.php | 69 +++++++++++++++++++++++--------- 3 files changed, 75 insertions(+), 29 deletions(-) diff --git a/modules/planka/api.php b/modules/planka/api.php index 06dce12..637ed1f 100644 --- a/modules/planka/api.php +++ b/modules/planka/api.php @@ -99,25 +99,28 @@ if ($action === 'set_active_board') { if ($action === 'create_list') { $board_id = $_GET['board_id'] ?? null; if (!$board_id) tErr('board_id manquant'); $name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant'); - $position = (int) ($_GET['position'] ?? 65535); + $position = (float) ($_GET['position'] ?? 65535); $resp = planka_api('POST', '/api/boards/' . $board_id . '/lists', [ 'name' => $name, 'position' => $position, + 'type' => 'active', ], $token); - if (empty($resp['item'])) tErr($resp['message'] ?? 'Planka n\'a pas créé la liste', 502); + 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'); - $board_id = $_GET['board_id'] ?? null; if (!$board_id) tErr('board_id manquant'); - $name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant'); - $resp = planka_api('POST', '/api/boards/' . $board_id . '/cards', [ - 'listId' => $list_id, + $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' => 65535, + 'position' => $pos, + 'type' => 'story', ], $token); - tOk($resp['item'] ?? []); + if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Erreur Planka'), 502); + tOk($resp['item']); } if ($action === 'update_card') { diff --git a/modules/planka/assets/planka.css b/modules/planka/assets/planka.css index eb8ab33..24d4cc2 100644 --- a/modules/planka/assets/planka.css +++ b/modules/planka/assets/planka.css @@ -488,3 +488,17 @@ 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; +} diff --git a/planka.php b/planka.php index 4af8c94..59ed373 100644 --- a/planka.php +++ b/planka.php @@ -150,24 +150,50 @@ function renderBoard() {
`; + // Drag source — cards area.querySelectorAll('.pk-card').forEach(el => { el.addEventListener('click', () => openCardModal(el.dataset.id)); el.addEventListener('dragstart', e => { - e.dataTransfer.setData('text/plain', el.dataset.id); + e.dataTransfer.setData('card-id', el.dataset.id); e.dataTransfer.effectAllowed = 'move'; - el.classList.add('is-dragging'); + setTimeout(() => el.classList.add('is-dragging'), 0); + }); + el.addEventListener('dragend', () => { + el.classList.remove('is-dragging'); + area.querySelectorAll('.drag-over,.drop-before-active').forEach(x => x.classList.remove('drag-over','drop-before-active')); }); - el.addEventListener('dragend', e => { el.classList.remove('is-dragging'); }); }); - // Drop target = toute la colonne .pk-cards + + // Drop zone — before a specific card + area.querySelectorAll('.pk-drop-before').forEach(el => { + el.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); el.classList.add('drop-before-active'); }); + el.addEventListener('dragleave', () => el.classList.remove('drop-before-active')); + el.addEventListener('drop', e => { + e.preventDefault(); e.stopPropagation(); + el.classList.remove('drop-before-active'); + const cardId = e.dataTransfer.getData('card-id'); + if (!cardId) return; + const listId = el.dataset.listId; + const prevPos = parseFloat(el.dataset.prevPos || 0); + const curPos = parseFloat(el.dataset.curPos || 0); + const newPos = prevPos === 0 ? curPos / 2 : (prevPos + curPos) / 2; + moveCard(cardId, listId, newPos); + }); + }); + + // Drop zone — end of column (whole .pk-cards) area.querySelectorAll('.pk-cards').forEach(el => { el.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; el.classList.add('drag-over'); }); el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); }); el.addEventListener('drop', e => { - e.preventDefault(); + e.preventDefault(); e.stopPropagation(); el.classList.remove('drag-over'); - const cardId = e.dataTransfer.getData('text/plain'); - if (cardId) dropCard(el.dataset.listId, cardId); + const cardId = e.dataTransfer.getData('card-id'); + if (!cardId) return; + const listId = el.dataset.listId; + const listCards = state.cards.filter(c => c.listId === listId && c.id !== cardId); + const maxPos = listCards.reduce((m, c) => Math.max(m, c.position || 0), 0); + moveCard(cardId, listId, maxPos + 65535); }); }); } @@ -180,15 +206,15 @@ function renderList(list) { ${esc(list.name)} ${cards.length} -
+
${cards.length === 0 ? '
Déposer ici
' : ''} - ${cards.map(c => renderCard(c)).join('')} + ${cards.map((c, i) => renderCard(c, cards[i-1] ?? null)).join('')}
`; } -function renderCard(card) { +function renderCard(card, prevCard) { const labels = (state.cardLabels || []).filter(cl => cl.cardId === card.id).map(cl => { const lbl = (state.labels || []).find(l => l.id === cl.labelId); if (!lbl) return ''; @@ -203,24 +229,25 @@ function renderCard(card) { return `📅 ${formatDate(card.dueDate)}`; })() : ''; const taskHtml = cardTasks.length ? `☑ ${doneCount}/${cardTasks.length}` : ''; + const prevPos = prevCard ? (prevCard.position ?? 0) : 0; return ` -
+
+
${labels ? `
${labels}
` : ''}
${esc(card.name)}
${dueHtml || taskHtml ? `
${dueHtml}${taskHtml}
` : ''}
`; } -async function dropCard(listId, cardId) { +async function moveCard(cardId, listId, position) { const card = state.cards.find(c => c.id === cardId); - if (!card || card.listId === listId) return; - // Planka requires position when changing listId - const targetCards = state.cards.filter(c => c.listId === listId); - const maxPos = targetCards.reduce((m, c) => Math.max(m, c.position || 0), 0); - const position = maxPos + 65535; - const res = await apiFetch('update_card', { id: cardId }, 'PATCH', { listId, position }); + if (!card) return; + // Skip no-op (same list, same position range) + if (card.listId === listId && Math.abs((card.position || 0) - position) < 1) return; + 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.listId = listId; card.position = position; renderBoard(); showToast('Carte déplacée ✓'); @@ -229,7 +256,9 @@ async function dropCard(listId, cardId) { async function quickAddCard(listId) { const name = prompt('Nom de la carte :'); 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; } state.cards.push(res.data); renderBoard(); From e770ccf22b41643d86a4d491149b242b229feab8 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 17:55:35 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix(planka):=20propagation=20erreurs=20Plan?= =?UTF-8?q?ka=20+=20noms=20=C3=A9tiquettes=20+=20position=20dans=20saveCar?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update_card: transmet position à Planka + retourne erreur réelle si Planka rejette - saveCard: n'envoie listId que si changé (position requise par Planka), sinon nom/desc/dueDate seul - Labels: affiche le nom en texte dans le badge coloré (comme Planka) Co-Authored-By: Claude Sonnet 4.6 --- modules/planka/api.php | 12 +++++++----- modules/planka/assets/planka.css | 16 ++++++++++++++-- planka.php | 23 +++++++++++++++-------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/modules/planka/api.php b/modules/planka/api.php index 637ed1f..82b2f8c 100644 --- a/modules/planka/api.php +++ b/modules/planka/api.php @@ -127,12 +127,14 @@ if ($action === 'update_card') { $id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant'); $d = tBody(); $payload = []; - if (isset($d['name'])) $payload['name'] = $d['name']; - if (isset($d['description'])) $payload['description'] = $d['description']; - if (array_key_exists('dueDate', $d)) $payload['dueDate'] = $d['dueDate']; - if (isset($d['listId'])) $payload['listId'] = $d['listId']; + if (isset($d['name'])) $payload['name'] = $d['name']; + if (isset($d['description'])) $payload['description'] = $d['description']; + if (array_key_exists('dueDate', $d)) $payload['dueDate'] = $d['dueDate']; + 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); - 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') { diff --git a/modules/planka/assets/planka.css b/modules/planka/assets/planka.css index 24d4cc2..d768a12 100644 --- a/modules/planka/assets/planka.css +++ b/modules/planka/assets/planka.css @@ -166,10 +166,22 @@ } .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; width: 36px; - border-radius: 3px; - display: inline-block; + padding: 0; } .pk-card-name { diff --git a/planka.php b/planka.php index 59ed373..9162fe0 100644 --- a/planka.php +++ b/planka.php @@ -218,7 +218,7 @@ function renderCard(card, prevCard) { const labels = (state.cardLabels || []).filter(cl => cl.cardId === card.id).map(cl => { const lbl = (state.labels || []).find(l => l.id === cl.labelId); if (!lbl) return ''; - return ``; + return `${esc(lbl.name || '')}`; }).join(''); const cardTasks = (state.tasks || []).filter(t => t.cardId === card.id); const doneCount = cardTasks.filter(t => t.isCompleted).length; @@ -296,22 +296,29 @@ function closeCardModal() { async function saveCard() { 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; } - const due = document.getElementById('pk-card-due').value; - const body = { + const due = document.getElementById('pk-card-due').value; + const listId = document.getElementById('pk-card-list').value; + const body = { name, description: document.getElementById('pk-card-desc').value, 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); - 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); - 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(); renderBoard(); - showToast('Carte enregistrée'); + showToast('Carte enregistrée ✓'); } async function deleteCard() { From 969bd5c2bf97d4aadc6dbdcdf95d33035e189741 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 18:15:07 +0200 Subject: [PATCH 9/9] =?UTF-8?q?fix(planka):=20tri=20intra-liste=20par=20dr?= =?UTF-8?q?op=20sur=20moiti=C3=A9=20haute/basse=20de=20carte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chaque carte est une zone de drop: moitié haute = avant, moitié basse = après - Indicateur visuel: bordure bleue top ou bottom selon la position du curseur - Suppression des pk-drop-before (zones 4px invisibles remplacées) - Colonne comme zone de secours pour listes vides Co-Authored-By: Claude Sonnet 4.6 --- modules/planka/assets/planka.css | 4 ++ planka.php | 69 ++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/modules/planka/assets/planka.css b/modules/planka/assets/planka.css index d768a12..5bf7bf7 100644 --- a/modules/planka/assets/planka.css +++ b/modules/planka/assets/planka.css @@ -514,3 +514,7 @@ 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; } diff --git a/planka.php b/planka.php index 9162fe0..4d62142 100644 --- a/planka.php +++ b/planka.php @@ -150,7 +150,7 @@ function renderBoard() {
`; - // Drag source — cards + // ── Drag source ────────────────────────────────────────────────────────── area.querySelectorAll('.pk-card').forEach(el => { el.addEventListener('click', () => openCardModal(el.dataset.id)); el.addEventListener('dragstart', e => { @@ -160,39 +160,60 @@ function renderBoard() { }); el.addEventListener('dragend', () => { el.classList.remove('is-dragging'); - area.querySelectorAll('.drag-over,.drop-before-active').forEach(x => x.classList.remove('drag-over','drop-before-active')); + area.querySelectorAll('.drop-top,.drop-bottom,.drag-over').forEach(x => + x.classList.remove('drop-top','drop-bottom','drag-over') + ); }); - }); - // Drop zone — before a specific card - area.querySelectorAll('.pk-drop-before').forEach(el => { - el.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); el.classList.add('drop-before-active'); }); - el.addEventListener('dragleave', () => el.classList.remove('drop-before-active')); + // ── 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(); - el.classList.remove('drop-before-active'); + const isBefore = el.classList.contains('drop-top'); + el.classList.remove('drop-top','drop-bottom'); const cardId = e.dataTransfer.getData('card-id'); - if (!cardId) return; - const listId = el.dataset.listId; - const prevPos = parseFloat(el.dataset.prevPos || 0); - const curPos = parseFloat(el.dataset.curPos || 0); - const newPos = prevPos === 0 ? curPos / 2 : (prevPos + curPos) / 2; - moveCard(cardId, listId, newPos); + 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); }); }); - // Drop zone — end of column (whole .pk-cards) + // ── Drop at end of column (empty list or after all cards) ─────────────── area.querySelectorAll('.pk-cards').forEach(el => { - el.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; el.classList.add('drag-over'); }); + 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 => { - e.preventDefault(); e.stopPropagation(); + 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 listCards = state.cards.filter(c => c.listId === listId && c.id !== cardId); - const maxPos = listCards.reduce((m, c) => Math.max(m, c.position || 0), 0); + 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); }); }); @@ -206,15 +227,15 @@ function renderList(list) { ${esc(list.name)} ${cards.length}
-
+
${cards.length === 0 ? '
Déposer ici
' : ''} - ${cards.map((c, i) => renderCard(c, cards[i-1] ?? null)).join('')} + ${cards.map(c => renderCard(c)).join('')}
`; } -function renderCard(card, prevCard) { +function renderCard(card) { const labels = (state.cardLabels || []).filter(cl => cl.cardId === card.id).map(cl => { const lbl = (state.labels || []).find(l => l.id === cl.labelId); if (!lbl) return ''; @@ -229,9 +250,7 @@ function renderCard(card, prevCard) { return `📅 ${formatDate(card.dueDate)}`; })() : ''; const taskHtml = cardTasks.length ? `☑ ${doneCount}/${cardTasks.length}` : ''; - const prevPos = prevCard ? (prevCard.position ?? 0) : 0; return ` -
${labels ? `
${labels}
` : ''}
${esc(card.name)}