feat(liste): modal création/édition avec couleur et type de liste
Deploy HouseHub / deploy (push) Successful in 2s

- 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 <noreply@anthropic.com>
This commit is contained in:
perco
2026-05-19 22:05:59 +02:00
co-authored by Claude Sonnet 4.6
parent ab97af0ea3
commit 7b294f300a
5 changed files with 325 additions and 82 deletions
+5 -3
View File
@@ -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;
-8
View File
@@ -18,14 +18,6 @@ require __DIR__ . '/header.php';
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div>
<!-- Formulaire de création de liste inline -->
<div class="liste-new-form hidden" id="liste-new-form">
<input type="text" id="new-list-name" maxlength="100"
placeholder="<?= htmlspecialchars(tr('liste_list_name_placeholder')) ?>" autocomplete="off">
<button id="btn-confirm-new-list"><?= htmlspecialchars(tr('liste_create')) ?></button>
<button id="btn-cancel-new-list"><?= htmlspecialchars(tr('cancel')) ?></button>
</div>
<!-- Corps de la liste active -->
<div class="liste-body" id="liste-body">
+19 -7
View File
@@ -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') {
+143
View File
@@ -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; }
+158 -64
View File
@@ -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 = `
<div class="liste-modal" role="dialog" aria-modal="true">
<div class="liste-modal-header">
<h3>${isNew ? 'Nouvelle liste' : 'Modifier la liste'}</h3>
<button class="liste-modal-close" aria-label="Fermer">×</button>
</div>
<div class="liste-modal-body">
<div class="liste-form-group">
<label class="liste-form-label">Nom</label>
<input class="liste-modal-name" type="text" maxlength="100"
value="${esc(list?.name ?? '')}" placeholder="Ma liste…" autocomplete="off">
</div>
<div class="liste-form-group">
<label class="liste-form-label">Couleur</label>
<div class="liste-color-picker">
${LIST_COLORS.map(c => {
const isActive = (c.hex === null && !list?.color) || (list?.color === c.hex);
const cls = 'liste-color-swatch' + (c.hex === null ? ' liste-color-swatch-none' : '') + (isActive ? ' active' : '');
const style = c.hex ? `style="--swatch:${c.hex}"` : '';
return `<button class="${cls}" data-color="${c.hex ?? ''}" ${style} title="${c.id}"></button>`;
}).join('')}
</div>
</div>
<div class="liste-form-group">
<label class="liste-form-label">Type de liste</label>
<select class="liste-modal-type">
${LIST_TYPES.map(t =>
`<option value="${esc(t.value)}" ${list?.list_type === t.value ? 'selected' : ''}>${t.emoji ? t.emoji + ' ' : ''}${esc(t.label)}</option>`
).join('')}
</select>
</div>
</div>
<div class="liste-modal-footer">
${!isNew && state.lists.length > 1
? `<button class="btn-tool btn-tool-danger liste-modal-delete">Supprimer</button>`
: ''}
<button class="btn-tool liste-modal-cancel">Annuler</button>
<button class="btn-tool btn-tool-primary liste-modal-save">Enregistrer</button>
</div>
</div>`;
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 ? `<span class="liste-tab-dot" style="background:${esc(list.color)}"></span>` : '';
const emoji = typeEmoji(list.list_type);
const prefix = emoji ? `<span class="liste-tab-type">${emoji}</span>` : '';
if (list.id === state.currentListId) {
tab.innerHTML = `
${dot}${prefix}
<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>` : ''}
<button class="liste-tab-btn liste-tab-edit" title="Modifier" data-id="${list.id}">✏</button>
`;
} else {
tab.innerHTML = `<span class="liste-tab-name">${esc(list.name)}</span>`;
tab.innerHTML = `${dot}${prefix}<span class="liste-tab-name">${esc(list.name)}</span>`;
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 `<div class="${cls}" data-id="${item.id}">