@@ -345,6 +345,16 @@ CREATE TABLE IF NOT EXISTS pf_todos (
|
||||
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
|
||||
) 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 ─────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
@@ -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'; ?>
|
||||
@@ -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('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('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; ?>
|
||||
</nav>
|
||||
<?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('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('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; ?>
|
||||
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
|
||||
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
|
||||
|
||||
@@ -99,6 +99,31 @@ return [
|
||||
'mod_calendar_ios_name' => 'Calendari iOS',
|
||||
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
|
||||
'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_explore' => 'Explorar',
|
||||
'cta_view_lists' => 'Desembolicar',
|
||||
@@ -106,6 +131,7 @@ return [
|
||||
'cta_service' => 'Revisar',
|
||||
'cta_jot' => 'Apuntar',
|
||||
'cta_check' => 'Marcar',
|
||||
'cta_cart' => 'Anar a comprar',
|
||||
'cta_sync' => 'Sincronitzar',
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -100,6 +100,31 @@ return [
|
||||
'mod_calendar_ios_name' => 'iOS Calendar',
|
||||
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
|
||||
'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_explore' => 'Explore',
|
||||
'cta_view_lists' => 'Unwrap',
|
||||
@@ -107,6 +132,7 @@ return [
|
||||
'cta_service' => 'Service',
|
||||
'cta_jot' => 'Jot down',
|
||||
'cta_check' => 'Check off',
|
||||
'cta_cart' => 'Go shopping',
|
||||
'cta_sync' => 'Sync',
|
||||
|
||||
// ==========================================
|
||||
|
||||
+26
-1
@@ -102,13 +102,38 @@ return [
|
||||
'mod_calendar_ios_name' => 'Calendrier iOS',
|
||||
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
|
||||
'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_explore' => 'Explorer',
|
||||
'cta_view_lists' => 'Déballer',
|
||||
'cta_manage' => 'Gérer',
|
||||
'cta_service' => 'Réviser',
|
||||
'cta_jot' => 'Griffonner',
|
||||
'cta_check' => 'Cocher',
|
||||
'cta_cart' => 'Faire les courses',
|
||||
'cta_sync' => 'Synchroniser',
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -109,6 +109,15 @@ if ($_has_custom_bg): ?>
|
||||
</a>
|
||||
<?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)): ?>
|
||||
<a href="/calendar-ios.php" class="pf-module-card">
|
||||
<div class="pf-card-icon">📱</div>
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
|
||||
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
@@ -36,7 +36,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
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])));
|
||||
if (empty($enabled)) {
|
||||
$error = "Vous devez garder au moins un module actif.";
|
||||
@@ -274,6 +274,7 @@ require __DIR__ . '/header.php';
|
||||
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
|
||||
'todo' => ['icon' => '✅', 'label' => tr('menu_todo')],
|
||||
'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')],
|
||||
'groceries' => ['icon' => '🛒', 'label' => tr('menu_groceries')],
|
||||
];
|
||||
?>
|
||||
<form method="post">
|
||||
|
||||
Reference in New Issue
Block a user