From 7b294f300a3cfb4d9b4217dbfb7c12e522ed0a25 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 19 May 2026 22:05:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(liste):=20modal=20cr=C3=A9ation/=C3=A9diti?= =?UTF-8?q?on=20avec=20couleur=20et=20type=20de=20liste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remplacement du formulaire inline par un modal complet - Color picker : 11 couleurs + option "sans couleur" - Type de liste : Courses, To-do, Voyage, Travail, Maison, Santé, Loisirs, Autre - Tab affiche un dot coloré + emoji du type - Bouton "Supprimer" dans le modal d'édition (remplace le × dans le tab) - DB : columns color + list_type sur pf_lists Co-Authored-By: Claude Sonnet 4.6 --- docker/schema_family.sql | 8 +- liste.php | 8 -- modules/liste/api.php | 26 ++-- modules/liste/assets/liste.css | 143 +++++++++++++++++++++ modules/liste/assets/liste.js | 222 +++++++++++++++++++++++---------- 5 files changed, 325 insertions(+), 82 deletions(-) diff --git a/docker/schema_family.sql b/docker/schema_family.sql index 4fe506c..4d7e727 100644 --- a/docker/schema_family.sql +++ b/docker/schema_family.sql @@ -374,9 +374,11 @@ CREATE TABLE IF NOT EXISTS pf_todos ( -- ─── 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, + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL DEFAULT 'Ma liste', + color VARCHAR(20) DEFAULT NULL, + list_type VARCHAR(50) DEFAULT NULL, + position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/liste.php b/liste.php index 1956885..2b2d936 100644 --- a/liste.php +++ b/liste.php @@ -18,14 +18,6 @@ require __DIR__ . '/header.php'; - - -
diff --git a/modules/liste/api.php b/modules/liste/api.php index 11f00b8..c82fd82 100644 --- a/modules/liste/api.php +++ b/modules/liste/api.php @@ -15,6 +15,7 @@ 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', + color VARCHAR(20) DEFAULT NULL, list_type VARCHAR(50) DEFAULT NULL, position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); @@ -48,6 +49,8 @@ $pdo->exec("CREATE TABLE IF NOT EXISTS pf_item_category_rules ( 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 COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {} +try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN color VARCHAR(20) DEFAULT NULL AFTER name"); } catch (\Exception $e) {} +try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN list_type VARCHAR(50) DEFAULT NULL AFTER color"); } catch (\Exception $e) {} // ── Seed categories if empty ────────────────────────────────────────────────── if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) { @@ -313,23 +316,32 @@ if ($action === 'set_category' && $method === 'POST') { if ($action === 'lists') { if ($method === 'GET') { $firstId = liste_ensure_default($pdo); - $rows = $pdo->query("SELECT id, name, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC); + $rows = $pdo->query("SELECT id, name, color, list_type, 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 = mb_substr(trim($body['name'] ?? ''), 0, 100); + $name = mb_substr(trim($body['name'] ?? ''), 0, 100); + $color = mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null; + $list_type = mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null; if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; } $pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn(); - $pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?,?)")->execute([$name, $pos]); - echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'position' => $pos]); + $pdo->prepare("INSERT INTO pf_lists (name, color, list_type, position) VALUES (?,?,?,?)")->execute([$name, $color, $list_type, $pos]); + echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'color' => $color, 'list_type' => $list_type, 'position' => $pos]); exit; } if ($method === 'PUT') { - $id = (int)($_GET['id'] ?? 0); - $name = mb_substr(trim($body['name'] ?? ''), 0, 100); + $id = (int)($_GET['id'] ?? 0); + $name = mb_substr(trim($body['name'] ?? ''), 0, 100); + $color = array_key_exists('color', $body) ? (mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null) : false; + $list_type = array_key_exists('list_type', $body) ? (mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null) : false; if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; } - $pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $id]); + $sets = ['name=?']; + $vals = [$name]; + if ($color !== false) { $sets[] = 'color=?'; $vals[] = $color; } + if ($list_type !== false) { $sets[] = 'list_type=?'; $vals[] = $list_type; } + $vals[] = $id; + $pdo->prepare("UPDATE pf_lists SET " . implode(', ', $sets) . " WHERE id=?")->execute($vals); echo json_encode(['ok' => true]); exit; } if ($method === 'DELETE') { diff --git a/modules/liste/assets/liste.css b/modules/liste/assets/liste.css index 445192a..8bacfb6 100644 --- a/modules/liste/assets/liste.css +++ b/modules/liste/assets/liste.css @@ -310,6 +310,142 @@ } .liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; } +/* ── List modal ────────────────────────────────────────────────────────────── */ + +.liste-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + padding: 1rem; +} + +.liste-modal { + background: var(--card-bg, #fff); + border: 1px solid var(--border, #dee2e6); + border-radius: 14px; + width: 100%; + max-width: 420px; + box-shadow: 0 20px 60px rgba(0,0,0,.2); +} + +.liste-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border, #dee2e6); +} +.liste-modal-header h3 { margin: 0; font-size: .95rem; font-weight: 700; color: var(--text, #212529); } + +.liste-modal-close { + background: none; + border: none; + font-size: 1.3rem; + cursor: pointer; + color: var(--muted, #6c757d); + line-height: 1; + padding: 0 .25rem; + transition: color .15s; +} +.liste-modal-close:hover { color: var(--text, #212529); } + +.liste-modal-body { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: .85rem; +} + +.liste-modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: .5rem; + padding: .85rem 1.25rem; + border-top: 1px solid var(--border, #dee2e6); +} +.liste-modal-footer .btn-tool-danger { margin-right: auto; } + +.liste-form-group { display: flex; flex-direction: column; gap: .3rem; } +.liste-form-label { + font-size: .73rem; + font-weight: 700; + color: var(--muted, #6c757d); + text-transform: uppercase; + letter-spacing: .04em; +} + +.liste-modal-name, .liste-modal-type { + padding: .5rem .7rem; + border: 1px solid var(--border, #dee2e6); + border-radius: 8px; + background: var(--input-bg, #fff); + color: var(--text, #212529); + font-size: .875rem; + font-family: inherit; + width: 100%; + box-sizing: border-box; + transition: border-color .15s; +} +.liste-modal-name:focus, .liste-modal-type:focus { + outline: none; + border-color: var(--primary, #4361ee); + box-shadow: 0 0 0 3px rgba(67,97,238,.15); +} + +.btn-tool-primary { + background: var(--primary, #4361ee); + color: #fff; + border-color: var(--primary, #4361ee); +} +.btn-tool-primary:hover { filter: brightness(1.1); background: var(--primary, #4361ee); } + +/* ── Color picker ──────────────────────────────────────────────────────────── */ + +.liste-color-picker { + display: flex; + flex-wrap: wrap; + gap: .45rem; +} + +.liste-color-swatch { + width: 26px; + height: 26px; + border-radius: 50%; + border: 3px solid transparent; + background: var(--swatch, transparent); + cursor: pointer; + transition: transform .1s; + outline: 2px solid transparent; + outline-offset: 2px; + box-sizing: border-box; +} +.liste-color-swatch:hover { transform: scale(1.2); } +.liste-color-swatch.active { + outline-color: var(--swatch, var(--primary, #4361ee)); + border-color: var(--card-bg, #fff); +} +.liste-color-swatch-none { + background: transparent !important; + border: 2px dashed var(--border, #dee2e6) !important; +} +.liste-color-swatch-none.active { outline-color: var(--primary, #4361ee); } + +/* ── Tab dot + type emoji ──────────────────────────────────────────────────── */ + +.liste-tab-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} +.liste-tab-type { font-size: .85rem; line-height: 1; } + /* ── Category section headers ──────────────────────────────────────────────── */ .liste-cat-section-header { @@ -429,6 +565,13 @@ [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; } +[data-theme='dark'] .liste-modal { background: #1e1e2e; border-color: #3a3a4e; } +[data-theme='dark'] .liste-modal-header, +[data-theme='dark'] .liste-modal-footer { border-color: #3a3a4e; } +[data-theme='dark'] .liste-modal-header h3 { color: #cdd6f4; } +[data-theme='dark'] .liste-modal-name, +[data-theme='dark'] .liste-modal-type { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; } +[data-theme='dark'] .liste-color-swatch.active { border-color: #1e1e2e; } [data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; } [data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; } [data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; } diff --git a/modules/liste/assets/liste.js b/modules/liste/assets/liste.js index c1906b4..4dfdf49 100644 --- a/modules/liste/assets/liste.js +++ b/modules/liste/assets/liste.js @@ -11,6 +11,35 @@ const state = { catMap: {}, }; +// ── Constants ────────────────────────────────────────────────────────────────── + +const LIST_COLORS = [ + { id: 'none', hex: null }, + { id: 'blue', hex: '#3b82f6' }, + { id: 'red', hex: '#ef4444' }, + { id: 'amber', hex: '#f59e0b' }, + { id: 'purple', hex: '#8b5cf6' }, + { id: 'pink', hex: '#ec4899' }, + { id: 'emerald', hex: '#10b981' }, + { id: 'orange', hex: '#f97316' }, + { id: 'cyan', hex: '#06b6d4' }, + { id: 'indigo', hex: '#6366f1' }, + { id: 'teal', hex: '#14b8a6' }, + { id: 'gray', hex: '#6b7280' }, +]; + +const LIST_TYPES = [ + { value: '', label: '— Aucun —', emoji: '' }, + { value: 'courses', label: 'Courses', emoji: '🛒' }, + { value: 'todo', label: 'To-do', emoji: '✅' }, + { value: 'voyage', label: 'Voyage', emoji: '✈️' }, + { value: 'travail', label: 'Travail', emoji: '💼' }, + { value: 'maison', label: 'Maison', emoji: '🏠' }, + { value: 'sante', label: 'Santé', emoji: '💊' }, + { value: 'loisirs', label: 'Loisirs', emoji: '🎮' }, + { value: 'autre', label: 'Autre', emoji: '📋' }, +]; + // ── Utilities ────────────────────────────────────────────────────────────────── function esc(s) { @@ -42,6 +71,116 @@ async function api(action, method = 'GET', body = null, params = {}) { return r.json(); } +// ── List modal ───────────────────────────────────────────────────────────────── + +let activeModal = null; + +function openListModal(listId = null) { + if (activeModal) { activeModal.remove(); activeModal = null; } + const list = listId ? state.lists.find(l => l.id === listId) : null; + const isNew = !listId; + + const backdrop = document.createElement('div'); + backdrop.className = 'liste-modal-backdrop'; + backdrop.innerHTML = ` + `; + + document.body.appendChild(backdrop); + activeModal = backdrop; + + const nameInput = backdrop.querySelector('.liste-modal-name'); + nameInput.focus(); + nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length); + + // Color swatches + backdrop.querySelectorAll('.liste-color-swatch').forEach(btn => { + btn.addEventListener('click', () => { + backdrop.querySelectorAll('.liste-color-swatch').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + + const close = () => { backdrop.remove(); activeModal = null; }; + backdrop.querySelector('.liste-modal-close').addEventListener('click', close); + backdrop.querySelector('.liste-modal-cancel').addEventListener('click', close); + backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }); + + backdrop.querySelector('.liste-modal-delete')?.addEventListener('click', () => { close(); deleteList(listId); }); + backdrop.querySelector('.liste-modal-save').addEventListener('click', () => saveListModal(backdrop, listId)); + nameInput.addEventListener('keydown', e => { + if (e.key === 'Enter') saveListModal(backdrop, listId); + if (e.key === 'Escape') close(); + }); +} + +async function saveListModal(backdrop, listId) { + const name = backdrop.querySelector('.liste-modal-name').value.trim(); + if (!name) { backdrop.querySelector('.liste-modal-name').focus(); return; } + + const activeSwatch = backdrop.querySelector('.liste-color-swatch.active'); + const color = activeSwatch?.dataset.color || null; + const list_type = backdrop.querySelector('.liste-modal-type').value || null; + + if (listId) { + const r = await api('lists', 'PUT', { name, color, list_type }, { id: listId }); + if (r.ok) { + const list = state.lists.find(l => l.id === listId); + if (list) { list.name = name; list.color = color; list.list_type = list_type; } + backdrop.remove(); activeModal = null; + renderTabs(); + toast('Liste mise à jour'); + } + } else { + const r = await api('lists', 'POST', { name, color, list_type }); + if (r.id) { + state.lists.push({ id: r.id, name, color: r.color, list_type: r.list_type, position: r.position }); + state.currentListId = r.id; + backdrop.remove(); activeModal = null; + await loadItems(); + renderTabs(); + toast('Liste créée : ' + name); + } + } +} + // ── Categories ───────────────────────────────────────────────────────────────── async function loadCategories() { @@ -104,49 +243,45 @@ function closeCategoryPicker() { // ── Tabs ─────────────────────────────────────────────────────────────────────── +function typeEmoji(list_type) { + return LIST_TYPES.find(t => t.value === list_type)?.emoji || ''; +} + 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; + const dot = list.color ? `` : ''; + const emoji = typeEmoji(list.list_type); + const prefix = emoji ? `${emoji}` : ''; + if (list.id === state.currentListId) { tab.innerHTML = ` + ${dot}${prefix} ${esc(list.name)} - - ${state.lists.length > 1 ? `` : ''} + `; } else { - tab.innerHTML = `${esc(list.name)}`; + tab.innerHTML = `${dot}${prefix}${esc(list.name)}`; tab.addEventListener('click', () => switchList(list.id)); } container.appendChild(tab); }); - container.querySelectorAll('.liste-tab-rename').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); }); - }); - container.querySelectorAll('.liste-tab-delete').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); }); + container.querySelectorAll('.liste-tab-edit').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); openListModal(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'); - } - }); -} +// ── List management ───────────────────────────────────────────────────────────── + +document.getElementById('btn-add-list')?.addEventListener('click', () => openListModal(null)); async function deleteList(listId) { if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return; @@ -161,41 +296,6 @@ async function deleteList(listId) { 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) { @@ -207,10 +307,7 @@ async function switchList(listId) { // ── Items ─────────────────────────────────────────────────────────────────────── async function loadItems() { - if (!state.currentListId) { - renderItems(); - return; - } + if (!state.currentListId) { renderItems(); return; } const r = await api('items', 'GET', null, { list_id: state.currentListId }); state.items = r.items || []; renderItems(); @@ -236,15 +333,12 @@ function renderItems() { let html = ''; if (hasCategories && state.categories.length) { - // Group pending items by category const groups = new Map(); pending.forEach(item => { const key = item.category_id ?? 0; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(item); }); - - // Render in category order, uncategorized last const catIds = state.categories.map(c => parseInt(c.id)); const orderedKeys = catIds.filter(id => groups.has(id)); if (groups.has(0)) orderedKeys.push(0); @@ -284,7 +378,7 @@ function rowHtml(item) { const checked = item.in_cart ? 'checked' : ''; const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row'; const cat = item.category_id ? state.catMap[item.category_id] : null; - const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty'); + const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty'); const badgeIcon = cat ? esc(cat.icon) : '🏷️'; const badgeTip = cat ? esc(cat.name) : 'Non classé'; return `