feat: remplace module Courses par Liste multi-listes nommées
Deploy HouseHub / deploy (push) Successful in 1s
Deploy HouseHub / deploy (push) Successful in 1s
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1a9d5d5d18
commit
8176fc4de0
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../includes/auth.php';
|
||||
require_login();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$pdo = get_family_pdo();
|
||||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// Auto-create tables if missing
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
list_id INT NOT NULL DEFAULT 1,
|
||||
label VARCHAR(500) NOT NULL,
|
||||
in_cart TINYINT(1) NOT NULL DEFAULT 0,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_items_list (list_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
label_hash CHAR(64) NOT NULL,
|
||||
label_display VARCHAR(500) NOT NULL,
|
||||
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_grocery_hist_hash (label_hash),
|
||||
KEY idx_hist_last (last_used_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
// Add list_id column if missing (migration)
|
||||
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']);
|
||||
@@ -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; }
|
||||
@@ -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,'>').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 = `
|
||||
<span class="liste-tab-name">${esc(list.name)}</span>
|
||||
<button class="liste-tab-btn liste-tab-rename" title="${esc(T.rename_list)}" data-id="${list.id}">✏</button>
|
||||
${state.lists.length > 1 ? `<button class="liste-tab-btn liste-tab-delete" title="Supprimer" data-id="${list.id}">×</button>` : ''}
|
||||
`;
|
||||
} else {
|
||||
tab.innerHTML = `<span class="liste-tab-name">${esc(list.name)}</span>`;
|
||||
tab.addEventListener('click', () => switchList(list.id));
|
||||
}
|
||||
container.appendChild(tab);
|
||||
});
|
||||
|
||||
// Rename button
|
||||
container.querySelectorAll('.liste-tab-rename').forEach(btn => {
|
||||
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); });
|
||||
});
|
||||
// Delete button
|
||||
container.querySelectorAll('.liste-tab-delete').forEach(btn => {
|
||||
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
|
||||
});
|
||||
}
|
||||
|
||||
function startRename(listId) {
|
||||
const list = state.lists.find(l => l.id === listId);
|
||||
if (!list) return;
|
||||
const name = prompt(T.new_name || 'Nouveau nom :', list.name);
|
||||
if (name === null || name.trim() === '') return;
|
||||
api('lists', 'PUT', { name: name.trim() }, { id: listId }).then(r => {
|
||||
if (r.ok) {
|
||||
list.name = name.trim();
|
||||
renderTabs();
|
||||
toast(T.list_renamed || 'Liste renommée');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteList(listId) {
|
||||
if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return;
|
||||
const r = await api('lists', 'DELETE', null, { id: listId });
|
||||
if (r.error) { toast(r.error, 'error'); return; }
|
||||
state.lists = state.lists.filter(l => l.id !== listId);
|
||||
if (state.currentListId === listId) {
|
||||
state.currentListId = state.lists[0]?.id ?? null;
|
||||
}
|
||||
await loadItems();
|
||||
renderTabs();
|
||||
toast(T.list_deleted || 'Liste supprimée');
|
||||
}
|
||||
|
||||
// ── List management ────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('btn-add-list')?.addEventListener('click', () => {
|
||||
document.getElementById('liste-new-form')?.classList.remove('hidden');
|
||||
document.getElementById('btn-add-list')?.classList.add('hidden');
|
||||
document.getElementById('new-list-name')?.focus();
|
||||
});
|
||||
document.getElementById('btn-cancel-new-list')?.addEventListener('click', cancelNewList);
|
||||
document.getElementById('btn-confirm-new-list')?.addEventListener('click', confirmNewList);
|
||||
document.getElementById('new-list-name')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') confirmNewList();
|
||||
if (e.key === 'Escape') cancelNewList();
|
||||
});
|
||||
|
||||
function cancelNewList() {
|
||||
document.getElementById('liste-new-form')?.classList.add('hidden');
|
||||
document.getElementById('btn-add-list')?.classList.remove('hidden');
|
||||
if (document.getElementById('new-list-name')) document.getElementById('new-list-name').value = '';
|
||||
}
|
||||
|
||||
async function confirmNewList() {
|
||||
const input = document.getElementById('new-list-name');
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
const r = await api('lists', 'POST', { name });
|
||||
if (r.id) {
|
||||
state.lists.push({ id: r.id, name: r.name, position: r.position });
|
||||
state.currentListId = r.id;
|
||||
cancelNewList();
|
||||
await loadItems();
|
||||
renderTabs();
|
||||
toast((T.list_created || 'Liste créée') + ' : ' + r.name);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Switch list ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function switchList(listId) {
|
||||
state.currentListId = listId;
|
||||
await loadItems();
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
// ── Items ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadItems() {
|
||||
if (!state.currentListId) {
|
||||
renderItems();
|
||||
return;
|
||||
}
|
||||
const r = await api('items', 'GET', null, { list_id: state.currentListId });
|
||||
state.items = r.items || [];
|
||||
renderItems();
|
||||
}
|
||||
|
||||
function renderItems() {
|
||||
const root = document.getElementById('liste-items-root');
|
||||
if (!root) return;
|
||||
const toolbar = document.getElementById('liste-toolbar');
|
||||
if (!state.currentListId || state.items.length === 0) {
|
||||
root.innerHTML = state.currentListId
|
||||
? `<div class="liste-empty-items"><span>📝</span><p>Liste vide — ajoutez un article ci-dessus.</p></div>`
|
||||
: '';
|
||||
if (toolbar) toolbar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (toolbar) toolbar.style.display = '';
|
||||
const pending = state.items.filter(i => !i.in_cart);
|
||||
const inCart = state.items.filter(i => i.in_cart);
|
||||
let html = '';
|
||||
if (pending.length) html += pending.map(rowHtml).join('');
|
||||
if (inCart.length) html += `<div class="liste-cart-divider">Dans le panier (${inCart.length})</div>` + inCart.map(rowHtml).join('');
|
||||
root.innerHTML = html;
|
||||
root.querySelectorAll('.liste-check').forEach(cb => {
|
||||
cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0));
|
||||
});
|
||||
root.querySelectorAll('.btn-edit-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => editItem(parseInt(btn.dataset.id)));
|
||||
});
|
||||
root.querySelectorAll('.btn-delete-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteItem(parseInt(btn.dataset.id)));
|
||||
});
|
||||
}
|
||||
|
||||
function rowHtml(item) {
|
||||
const checked = item.in_cart ? 'checked' : '';
|
||||
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
|
||||
return `<div class="${cls}" data-id="${item.id}">
|
||||
<label class="liste-check-label">
|
||||
<input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}>
|
||||
<span class="liste-label">${esc(item.label)}</span>
|
||||
</label>
|
||||
<div class="liste-row-actions">
|
||||
<button class="btn-edit-item" data-id="${item.id}" title="Modifier">✏</button>
|
||||
<button class="btn-delete-item" data-id="${item.id}" title="Supprimer">🗑</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function addItem(label) {
|
||||
const r = await api('items', 'POST', { list_id: state.currentListId, label });
|
||||
if (r.duplicate) { toast(T.already_in || 'Déjà dans la liste', 'warn'); return; }
|
||||
if (r.id) {
|
||||
state.items.push(r);
|
||||
renderItems();
|
||||
renderHistory();
|
||||
toast(T.added || 'Ajouté');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCart(id, next) {
|
||||
await api('items', 'PUT', { in_cart: next }, { id });
|
||||
const item = state.items.find(i => i.id === id);
|
||||
if (item) item.in_cart = next;
|
||||
renderItems();
|
||||
}
|
||||
|
||||
async function editItem(id) {
|
||||
const item = state.items.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
const newLabel = prompt(T.edit_placeholder || 'Modifier :', item.label);
|
||||
if (newLabel === null || newLabel.trim() === '') return;
|
||||
await api('items', 'PUT', { label: newLabel.trim() }, { id });
|
||||
item.label = newLabel.trim();
|
||||
renderItems();
|
||||
toast(T.updated || 'Modifié');
|
||||
}
|
||||
|
||||
async function deleteItem(id) {
|
||||
await api('items', 'DELETE', null, { id });
|
||||
state.items = state.items.filter(i => i.id !== id);
|
||||
renderItems();
|
||||
toast(T.deleted || 'Supprimé');
|
||||
}
|
||||
|
||||
// ── Toolbar ────────────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('btn-uncheck-all')?.addEventListener('click', async () => {
|
||||
if (!state.currentListId) return;
|
||||
await api('uncheck_all', 'POST', { list_id: state.currentListId });
|
||||
state.items.forEach(i => i.in_cart = 0);
|
||||
renderItems();
|
||||
});
|
||||
|
||||
document.getElementById('btn-delete-picked')?.addEventListener('click', async () => {
|
||||
if (!state.currentListId) return;
|
||||
await api('delete_picked', 'POST', { list_id: state.currentListId });
|
||||
state.items = state.items.filter(i => !i.in_cart);
|
||||
renderItems();
|
||||
toast(T.deleted || 'Supprimés');
|
||||
});
|
||||
|
||||
document.getElementById('btn-clear-all')?.addEventListener('click', async () => {
|
||||
if (!confirm(T.confirm_clear || 'Vider toute la liste ?')) return;
|
||||
await api('clear_all', 'POST', { list_id: state.currentListId });
|
||||
state.items = [];
|
||||
renderItems();
|
||||
toast(T.deleted || 'Liste vidée');
|
||||
});
|
||||
|
||||
// ── Add input ──────────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('btn-liste-add')?.addEventListener('click', () => {
|
||||
const input = document.getElementById('liste-input');
|
||||
const v = input?.value.trim();
|
||||
if (v) { addItem(v); input.value = ''; input.focus(); }
|
||||
});
|
||||
|
||||
document.getElementById('liste-input')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('btn-liste-add')?.click();
|
||||
});
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadHistory() {
|
||||
const r = await api('history');
|
||||
state.history = r.history || [];
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
const root = document.getElementById('liste-history-root');
|
||||
if (!root || !state.history.length) { if (root) root.innerHTML = ''; return; }
|
||||
const currentLabels = new Set(state.items.map(i => i.label.toLowerCase().trim()));
|
||||
const suggestions = state.history.filter(h => !currentLabels.has(h.toLowerCase().trim()));
|
||||
if (!suggestions.length) { root.innerHTML = ''; return; }
|
||||
root.innerHTML = `
|
||||
<div class="liste-history-card">
|
||||
<div class="liste-history-title">${T.history_title || 'Récemment utilisés'}</div>
|
||||
<div class="liste-history-chips">
|
||||
${suggestions.map(h => `<button class="liste-chip" data-label="${esc(h)}">${esc(h)}</button>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
root.querySelectorAll('.liste-chip').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (state.currentListId) addItem(btn.dataset.label);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
const r = await api('lists');
|
||||
state.lists = r.lists || [];
|
||||
state.currentListId = r.default_id || state.lists[0]?.id || null;
|
||||
renderTabs();
|
||||
await Promise.all([loadItems(), loadHistory()]);
|
||||
}
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user