ajout liste de course
Deploy HouseHub / deploy (push) Successful in 1s

This commit is contained in:
2026-05-14 10:43:17 +02:00
parent a2e0061dde
commit e6de684f28
11 changed files with 681 additions and 2 deletions
+126
View File
@@ -0,0 +1,126 @@
<?php
ob_start();
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
set_exception_handler(function (\Throwable $e) {
if (!headers_sent()) {
header('Content-Type: application/json');
http_response_code(500);
}
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
exit;
});
require_once dirname(__DIR__, 2) . '/includes/db.php';
$pdo->exec("
CREATE TABLE IF NOT EXISTS pf_grocery_items (
id INT AUTO_INCREMENT PRIMARY KEY,
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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
function gOk($d)
{
echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE);
exit;
}
function gErr($m, $c = 400)
{
http_response_code($c);
echo json_encode(['ok' => false, 'error' => $m]);
exit;
}
function gBody()
{
return json_decode(file_get_contents('php://input'), true) ?? [];
}
if ($action === 'items') {
if ($method === 'GET') {
$rows = $pdo->query(
"SELECT id, label, in_cart, position, created_at, updated_at
FROM pf_grocery_items
ORDER BY in_cart ASC, position ASC, id ASC"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['in_cart'] = (int) $r['in_cart'];
$r['id'] = (int) $r['id'];
$r['position'] = (int) $r['position'];
}
unset($r);
gOk($rows);
}
if ($method === 'POST') {
$d = gBody();
$label = trim($d['label'] ?? '');
if ($label === '') {
gErr('Libellé requis');
}
$max = (int) $pdo->query('SELECT COALESCE(MAX(position),0) FROM pf_grocery_items')->fetchColumn();
$pdo->prepare('INSERT INTO pf_grocery_items (label, position) VALUES (?,?)')->execute([$label, $max + 1]);
$id = (int) $pdo->lastInsertId();
$s = $pdo->prepare('SELECT id, label, in_cart, position, created_at, updated_at FROM pf_grocery_items WHERE id=?');
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
$row['in_cart'] = (int) $row['in_cart'];
$row['id'] = (int) $row['id'];
$row['position'] = (int) $row['position'];
gOk($row);
}
if ($method === 'PUT') {
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id < 1) {
gErr('ID manquant');
}
$d = gBody();
if (array_key_exists('in_cart', $d)) {
$in = !empty($d['in_cart']) ? 1 : 0;
$pdo->prepare('UPDATE pf_grocery_items SET in_cart=?, updated_at=NOW() WHERE id=?')->execute([$in, $id]);
gOk(['in_cart' => $in]);
}
$label = trim($d['label'] ?? '');
if ($label === '') {
gErr('Libellé requis');
}
$pdo->prepare('UPDATE pf_grocery_items SET label=?, updated_at=NOW() WHERE id=?')->execute([$label, $id]);
gOk(['updated' => true]);
}
if ($method === 'DELETE') {
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id < 1) {
gErr('ID manquant');
}
$pdo->prepare('DELETE FROM pf_grocery_items WHERE id=?')->execute([$id]);
gOk(['deleted' => true]);
}
}
if ($action === 'uncheck_all' && $method === 'POST') {
$pdo->exec('UPDATE pf_grocery_items SET in_cart=0');
gOk(['updated' => true]);
}
if ($action === 'delete_picked' && $method === 'POST') {
$pdo->exec('DELETE FROM pf_grocery_items WHERE in_cart=1');
gOk(['deleted' => true]);
}
gErr('Action inconnue', 404);
+174
View File
@@ -0,0 +1,174 @@
/* HouseHub — Courses (liste de courses) */
.groceries-layout {
max-width: 560px;
margin: 0 auto;
padding: 0 1rem 2.5rem;
}
.groceries-hero {
padding: 1.25rem 0 1rem;
}
.groceries-hero h1 {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.35rem;
color: var(--text-main);
}
.groceries-hero p {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted);
}
.groceries-quick-add {
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.groceries-quick-add input {
flex: 1;
min-width: 0;
}
.groceries-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.groceries-section-title {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 1rem 0 0.5rem;
}
.groceries-section-title:first-of-type {
margin-top: 0;
}
.groceries-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.groceries-row {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.65rem 0.75rem;
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 10px;
transition: border-color 0.12s, box-shadow 0.12s;
}
.groceries-row:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.groceries-row.in-cart {
opacity: 0.62;
}
.groceries-row.in-cart .groceries-label {
text-decoration: line-through;
color: var(--text-muted);
}
.groceries-check {
width: 2.25rem;
height: 2.25rem;
border-radius: 8px;
border: 2px solid var(--border-light);
background: var(--bg-page);
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
color: transparent;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.groceries-check:hover {
border-color: var(--primary);
background: #eff6ff;
}
.groceries-check.checked {
background: var(--success);
border-color: var(--success);
color: #fff;
}
.groceries-check.checked::after {
content: '✓';
}
.groceries-label {
flex: 1;
min-width: 0;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-main);
line-height: 1.35;
word-break: break-word;
}
.groceries-row-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.groceries-empty {
text-align: center;
padding: 3rem 1.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.groceries-empty .groceries-empty-icon {
font-size: 2.75rem;
opacity: 0.35;
margin-bottom: 0.75rem;
}
.groceries-toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.groceries-toast {
background: var(--bg-panel);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 0.7rem 1rem;
font-size: 0.875rem;
min-width: 200px;
color: var(--text-main);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border-left: 4px solid var(--primary);
}
.groceries-toast.error {
border-left-color: var(--danger);
}
[data-theme='dark'] .groceries-row {
background: var(--bg-panel);
border-color: var(--border-light);
}
[data-theme='dark'] .groceries-row:hover {
border-color: #484f58;
}
[data-theme='dark'] .groceries-check {
border-color: #484f58;
background: var(--bg-page);
}
[data-theme='dark'] .groceries-check:hover {
background: rgba(59, 130, 246, 0.15);
border-color: var(--primary);
}
+245
View File
@@ -0,0 +1,245 @@
// HouseHub — Liste de courses
const API = '/modules/groceries/api.php';
function escHtml(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML;
}
function T(key, fallback) {
if (typeof tr === 'function') {
const v = tr(key);
if (v && v !== key) return v;
}
return fallback;
}
function escAttr(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
function toast(msg, type = 'success') {
const c = document.getElementById('groceries-toasts');
const t = document.createElement('div');
t.className = 'groceries-toast' + (type === 'error' ? ' error' : '');
t.textContent = msg;
c.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
async function api(action, method = 'GET', data = null, extra = '') {
const opts = {
method,
credentials: 'same-origin',
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
};
if (data) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(data);
}
const r = await fetch(API + '?action=' + action + extra, opts);
const text = await r.text();
let j;
try {
j = JSON.parse(text);
} catch (e) {
console.error('Bad JSON from API:', text.slice(0, 200));
throw new Error(T('error_occured', 'Erreur serveur'));
}
if (!j.ok) throw new Error(j.error || T('error_occured', 'Erreur'));
return j.data;
}
async function loadItems() {
try {
const items = await api('items', 'GET');
render(items);
updateSubtitle(items);
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
function updateSubtitle(items) {
const el = document.getElementById('groceries-subtitle');
if (!el) return;
const pending = items.filter((i) => !parseInt(i.in_cart, 10)).length;
const cart = items.filter((i) => parseInt(i.in_cart, 10)).length;
if (!items.length) {
el.textContent = '';
return;
}
const tpl = typeof tr === 'function' ? tr('groceries_subtitle') : '';
el.textContent = (tpl && tpl !== 'groceries_subtitle' ? tpl : '{pending} à prendre · {cart} dans le caddie')
.replace('{pending}', String(pending))
.replace('{cart}', String(cart));
}
function render(items) {
const wrap = document.getElementById('groceries-lists');
if (!items.length) {
const msg = T('groceries_empty', 'Rien pour le moment. Ajoutez un produit ci-dessus.');
wrap.innerHTML =
'<div class="groceries-empty"><div class="groceries-empty-icon">🛒</div><p>' + escHtml(msg) + '</p></div>';
return;
}
const pending = items.filter((i) => !parseInt(i.in_cart, 10));
const cart = items.filter((i) => parseInt(i.in_cart, 10));
const labPending = escHtml(T('groceries_section_pending', 'À prendre'));
const labCart = escHtml(T('groceries_section_cart', 'Dans le caddie'));
let html = '';
if (pending.length) {
html += '<div class="groceries-section-title">' + labPending + '</div><div class="groceries-list">';
pending.forEach((i) => {
html += rowHtml(i, false);
});
html += '</div>';
}
if (cart.length) {
html += '<div class="groceries-section-title">' + labCart + '</div><div class="groceries-list">';
cart.forEach((i) => {
html += rowHtml(i, true);
});
html += '</div>';
}
wrap.innerHTML = html;
}
function rowHtml(i, inCart) {
const id = i.id;
const checkClass = 'groceries-check' + (inCart ? ' checked' : '');
const rowClass = 'groceries-row' + (inCart ? ' in-cart' : '');
const aria = escAttr(T('groceries_aria_toggle', 'Marquer dans le caddie'));
return (
'<div class="' +
rowClass +
'" data-id="' +
id +
'">' +
'<button type="button" class="' +
checkClass +
'" onclick="toggleCart(' +
id +
',' +
(inCart ? '0' : '1') +
')" aria-label="' +
aria +
'"></button>' +
'<div class="groceries-label">' +
escHtml(i.label) +
'</div>' +
'<div class="groceries-row-actions">' +
'<button type="button" class="btn btn-secondary btn-sm" onclick="editItem(' +
id +
')">✏️</button>' +
'<button type="button" class="btn btn-danger btn-sm" onclick="deleteItem(' +
id +
')">🗑️</button>' +
'</div></div>'
);
}
async function addFromInput() {
const input = document.getElementById('groceries-new');
const label = (input.value || '').trim();
if (!label) return;
try {
await api('items', 'POST', { label: label });
input.value = '';
input.focus();
toast(T('groceries_added', 'Ajouté'));
loadItems();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function toggleCart(id, next) {
try {
await api('items', 'PUT', { in_cart: !!parseInt(next, 10) }, '&id=' + id);
loadItems();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function editItem(id) {
const row = document.querySelector('.groceries-row[data-id="' + id + '"]');
const current = row ? row.querySelector('.groceries-label').textContent : '';
const label = window.prompt(T('groceries_prompt_edit', 'Modifier le produit'), current);
if (label === null) return;
const t = label.trim();
if (!t) {
toast(T('groceries_label_empty', 'Libellé vide'), 'error');
return;
}
try {
await api('items', 'PUT', { label: t }, '&id=' + id);
toast(T('groceries_updated', 'Mis à jour'));
loadItems();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function deleteItem(id) {
const ok = await pachaConfirm(
T('groceries_confirm_del_title', 'Supprimer ?'),
T('groceries_confirm_del_body', 'Retirer ce produit de la liste ?')
);
if (!ok) return;
try {
await api('items', 'DELETE', null, '&id=' + id);
toast(T('groceries_deleted', 'Supprimé'));
loadItems();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function uncheckAll() {
const ok = await pachaConfirm(
T('groceries_confirm_uncheck_title', 'Tout remettre à prendre'),
T('groceries_confirm_uncheck_body', '')
);
if (!ok) return;
try {
await api('uncheck_all', 'POST', {});
toast(T('groceries_reset_next', 'Liste prête pour la prochaine sortie'));
loadItems();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
async function deletePicked() {
const ok = await pachaConfirm(
T('groceries_confirm_clear_title', 'Retirer les produits du caddie'),
T('groceries_confirm_clear_body', '')
);
if (!ok) return;
try {
await api('delete_picked', 'POST', {});
toast(T('groceries_picked_removed', 'Articles retirés'));
loadItems();
} catch (e) {
toast(e.message || T('error_occured', 'Erreur'), 'error');
}
}
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('groceries-new');
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addFromInput();
}
});
}
loadItems();
});