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
+10
View File
@@ -345,6 +345,16 @@ CREATE TABLE IF NOT EXISTS pf_todos (
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Liste de courses ─────────────────────────────────────────────────────────
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;
-- ─── Calendar iOS ───────────────────────────────────────────────────────────── -- ─── Calendar iOS ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_calendar_events ( CREATE TABLE IF NOT EXISTS pf_calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
+35
View File
@@ -0,0 +1,35 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = tr('mod_groceries_name') . ' — HouseHub';
$activePage = 'groceries';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/groceries/assets/groceries.css">
<div id="groceries-toasts" class="groceries-toast-container"></div>
<div class="groceries-layout">
<div class="groceries-hero">
<h1>🛒 <?= htmlspecialchars(tr('mod_groceries_name')) ?></h1>
<p><?= htmlspecialchars(tr('mod_groceries_desc')) ?></p>
<p id="groceries-subtitle" class="pf-muted-note" style="margin-top:0.5rem;font-size:0.85rem"></p>
</div>
<div class="groceries-quick-add">
<input type="text" id="groceries-new" class="form-control" placeholder="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>" autocomplete="off" maxlength="500">
<button type="button" class="btn btn-primary" onclick="addFromInput()"><?= htmlspecialchars(tr('groceries_btn_add')) ?></button>
</div>
<div class="groceries-toolbar">
<button type="button" class="btn btn-secondary btn-sm" onclick="uncheckAll()"><?= htmlspecialchars(tr('groceries_btn_uncheck_all')) ?></button>
<button type="button" class="btn btn-secondary btn-sm" onclick="deletePicked()"><?= htmlspecialchars(tr('groceries_btn_delete_picked')) ?></button>
</div>
<div id="groceries-lists"></div>
</div>
<script src="/modules/groceries/assets/groceries.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+2
View File
@@ -55,6 +55,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?> <?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?> <?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?> <?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('groceries', $mods)): ?><a href="/groceries.php" class="pf-nav-link <?= $activePage === 'groceries' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_groceries') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?> <?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
</nav> </nav>
<?php endif; ?> <?php endif; ?>
@@ -103,6 +104,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?> <?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?> <?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?> <?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('groceries',$mods)): ?><a href="/groceries.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_groceries') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?> <?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a> <a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
<?php if (!empty($_SESSION['user']['is_admin'])): ?> <?php if (!empty($_SESSION['user']['is_admin'])): ?>
+26
View File
@@ -99,6 +99,31 @@ return [
'mod_calendar_ios_name' => 'Calendari iOS', 'mod_calendar_ios_name' => 'Calendari iOS',
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).', 'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo', 'menu_todo' => 'Todo',
'mod_groceries_name' => 'Compra',
'mod_groceries_desc' => 'Llista compartida: afegiu productes i marqueu-los quan siguin al carretó.',
'menu_groceries' => 'Compra',
'groceries_placeholder_add' => 'Ex : llet, pa, tomàquets…',
'groceries_btn_add' => 'Afegir',
'groceries_btn_uncheck_all' => 'Tornar a marcar com a pendents',
'groceries_btn_delete_picked' => 'Eliminar els marcats',
'groceries_subtitle' => '{pending} pendents · {cart} al carretó',
'groceries_added' => 'Afegit',
'groceries_updated' => 'Actualitzat',
'groceries_deleted' => 'Eliminat',
'groceries_reset_next' => 'Llista llesta per a la propera compra',
'groceries_picked_removed' => 'Articles eliminats',
'groceries_label_empty' => 'El text no pot ser buit',
'groceries_confirm_del_title' => 'Eliminar?',
'groceries_confirm_del_body' => 'Voleu treure aquest producte de la llista?',
'groceries_confirm_uncheck_title' => 'Tornar a marcar com a pendents',
'groceries_confirm_uncheck_body' => 'Es netejaran les marques del carretó. Els productes es mantindran a la llista.',
'groceries_confirm_clear_title' => 'Eliminar els marcats',
'groceries_confirm_clear_body' => 'Tots els articles marcats se suprimiran de la llista.',
'groceries_empty' => 'Encara no hi ha res. Afegiu un producte a dalt.',
'groceries_aria_toggle' => 'Marcar com al carretó o pendent',
'groceries_section_pending' => 'Pendents',
'groceries_section_cart' => 'Al carretó',
'groceries_prompt_edit' => 'Editar el producte',
'cta_open' => 'Obrir', 'cta_open' => 'Obrir',
'cta_explore' => 'Explorar', 'cta_explore' => 'Explorar',
'cta_view_lists' => 'Desembolicar', 'cta_view_lists' => 'Desembolicar',
@@ -106,6 +131,7 @@ return [
'cta_service' => 'Revisar', 'cta_service' => 'Revisar',
'cta_jot' => 'Apuntar', 'cta_jot' => 'Apuntar',
'cta_check' => 'Marcar', 'cta_check' => 'Marcar',
'cta_cart' => 'Anar a comprar',
'cta_sync' => 'Sincronitzar', 'cta_sync' => 'Sincronitzar',
// ========================================== // ==========================================
+26
View File
@@ -100,6 +100,31 @@ return [
'mod_calendar_ios_name' => 'iOS Calendar', 'mod_calendar_ios_name' => 'iOS Calendar',
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).', 'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
'menu_todo' => 'Todo', 'menu_todo' => 'Todo',
'mod_groceries_name' => 'Groceries',
'mod_groceries_desc' => 'Shared list: add items and check them off when they are in your cart.',
'menu_groceries' => 'Groceries',
'groceries_placeholder_add' => 'e.g. milk, bread, tomatoes…',
'groceries_btn_add' => 'Add',
'groceries_btn_uncheck_all' => 'Reset cart checks',
'groceries_btn_delete_picked' => 'Remove checked items',
'groceries_subtitle' => '{pending} to pick up · {cart} in cart',
'groceries_added' => 'Added',
'groceries_updated' => 'Updated',
'groceries_deleted' => 'Removed',
'groceries_reset_next' => 'List ready for your next trip',
'groceries_picked_removed' => 'Checked items removed',
'groceries_label_empty' => 'Label cannot be empty',
'groceries_confirm_del_title' => 'Delete?',
'groceries_confirm_del_body' => 'Remove this product from the list?',
'groceries_confirm_uncheck_title' => 'Reset cart checks',
'groceries_confirm_uncheck_body' => 'All “in cart” checkmarks will be cleared. Items stay on the list.',
'groceries_confirm_clear_title' => 'Remove checked items',
'groceries_confirm_clear_body' => 'Every checked item will be deleted from the list.',
'groceries_empty' => 'Nothing yet. Add a product above.',
'groceries_aria_toggle' => 'Mark as in cart or still to pick up',
'groceries_section_pending' => 'To pick up',
'groceries_section_cart' => 'In cart',
'groceries_prompt_edit' => 'Edit product',
'cta_open' => 'Open', 'cta_open' => 'Open',
'cta_explore' => 'Explore', 'cta_explore' => 'Explore',
'cta_view_lists' => 'Unwrap', 'cta_view_lists' => 'Unwrap',
@@ -107,6 +132,7 @@ return [
'cta_service' => 'Service', 'cta_service' => 'Service',
'cta_jot' => 'Jot down', 'cta_jot' => 'Jot down',
'cta_check' => 'Check off', 'cta_check' => 'Check off',
'cta_cart' => 'Go shopping',
'cta_sync' => 'Sync', 'cta_sync' => 'Sync',
// ========================================== // ==========================================
+26 -1
View File
@@ -102,13 +102,38 @@ return [
'mod_calendar_ios_name' => 'Calendrier iOS', 'mod_calendar_ios_name' => 'Calendrier iOS',
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).', 'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
'menu_todo' => 'Todo', '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',
'cta_open' => 'Ouvrir', 'cta_open' => 'Ouvrir',
'cta_explore' => 'Explorer', 'cta_explore' => 'Explorer',
'cta_view_lists' => 'Déballer',
'cta_manage' => 'Gérer', 'cta_manage' => 'Gérer',
'cta_service' => 'Réviser', 'cta_service' => 'Réviser',
'cta_jot' => 'Griffonner', 'cta_jot' => 'Griffonner',
'cta_check' => 'Cocher', 'cta_check' => 'Cocher',
'cta_cart' => 'Faire les courses',
'cta_sync' => 'Synchroniser', 'cta_sync' => 'Synchroniser',
// ========================================== // ==========================================
+9
View File
@@ -109,6 +109,15 @@ if ($_has_custom_bg): ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if (in_array('groceries', $mods)): ?>
<a href="/groceries.php" class="pf-module-card">
<div class="pf-card-icon">🛒</div>
<h3 class="pf-card-title"><?= tr('mod_groceries_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_groceries_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_cart') ?></span>
</a>
<?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?> <?php if (in_array('calendar_ios', $mods)): ?>
<a href="/calendar-ios.php" class="pf-module-card"> <a href="/calendar-ios.php" class="pf-module-card">
<div class="pf-card-icon">📱</div> <div class="pf-card-icon">📱</div>
+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();
});
+2 -1
View File
@@ -36,7 +36,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
if ($action === 'set_modules' && $family_id) { if ($action === 'set_modules' && $family_id) {
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'calendar_ios']; $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'calendar_ios', 'groceries'];
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m]))); $enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
if (empty($enabled)) { if (empty($enabled)) {
$error = "Vous devez garder au moins un module actif."; $error = "Vous devez garder au moins un module actif.";
@@ -274,6 +274,7 @@ require __DIR__ . '/header.php';
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')], 'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
'todo' => ['icon' => '✅', 'label' => tr('menu_todo')], 'todo' => ['icon' => '✅', 'label' => tr('menu_todo')],
'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')], 'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')],
'groceries' => ['icon' => '🛒', 'label' => tr('menu_groceries')],
]; ];
?> ?>
<form method="post"> <form method="post">